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 the clusters below: 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 issuer that owns every
session decision, AuthSessionIssuer /
IAuthSessionIssuer, with its two 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, and the one
complexity rule both server and client read, PasswordComplexity); 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. Five further clusters are opt-in identity completions:
a second factor (ITwoFactorService /
TotpTwoFactorService,
ITwoFactorAuthenticator, ITwoFactorStore,
ITwoFactorUserState); email confirmation
(IEmailConfirmationTokenService /
EmailConfirmationTokenService,
IEmailConfirmableUser); operator-editable permission grants
(PermissionGrant, IPermissionGrantStore,
LayeredPermissionRegistry); a user and role administration
API (IUserAdministrationService<TUserDto>,
IRoleAdministrationService,
StoredPermissionRoleAdministrationService); and
Terms of Service acceptance (LegalAcceptancePolicy,
ILegalAcceptanceService,
ILegalAcceptingUser). Each of
those five is a set of contracts plus an abstract or concrete base behind its own Add* call, never a
framework feature with a framework table, so a host that does not opt in pays nothing.
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), and
ADR-116 (the second
factor, email confirmation, operator-editable permission grants, and the administration API, all
shipped as contracts and bases behind their own Add* calls rather than as framework features). Two
neighbouring records also reach into this chapter by dependency grouping:
ADR-115 owns the
MMCA.Common.Shared.Identifiers wrapper-struct capability, and
ADR-031 owns the
[FeatureFlag] lifecycle metadata in MMCA.Common.Shared.FeatureFlags. 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:155-158, JwtSettings.cs:64), both produced by
TokenService
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/TokenService.cs:26). The access token
carries a fixed claim spine: sub, jti, iat, plus name, email, and role
(TokenService.cs:113-122), and the app adds its own claims (for example speaker_id or
customer_id) through the additionalClaims parameter (TokenService.cs:124-127). 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:110-112). 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:162, TokenService.cs:165).
The load-bearing design choice is a single configuration switch,
JwtSettings.SigningAlgorithm
(TokenService.cs:87). 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:87-98, TokenService.cs:226-238). 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:262-276), 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:255-259). Key material is materialized once in the constructor
and the owned RSA handles are disposed with the service (TokenService.cs:39-40,
TokenService.cs:220-224), 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:23) 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:187) and then re-checks the
token header after ValidateToken returns (TokenService.cs:196-200). Only the lifetime check is
skipped there (TokenService.cs:182), 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:64), 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:464, AuthenticationServiceBase.cs:470),
CreateUser (AuthenticationServiceBase.cs:473), CreateAccessToken
(AuthenticationServiceBase.cs:476), the two optional candidate gates
(AuthenticationServiceBase.cs:581-586), the post-commit OnUserRegisteredAsync
(AuthenticationServiceBase.cs:592), and the overridable "refresh user vanished" error
(AuthenticationServiceBase.cs:600, 401 by default because a token for a deleted user is
indistinguishable from an invalid one). What the base does not own is the session: it decides who is
signed in and hands everything about what they are given to
IAuthSessionIssuer, a constructor dependency
(AuthenticationServiceBase.cs:41-44, AuthenticationServiceBase.cs:69), so the workflow never
touches a session row. Even the token service a subclass mints through is the issuer's
(AuthenticationServiceBase.cs:99), which is how the sid and mfa claims reach an app's token
without the app's claim code knowing they exist (see the refresh-session section below).
LoginAsync (AuthenticationServiceBase.cs:123) shows the shape. It validates the request first,
then runs the ADR-029
lockout check (AuthenticationServiceBase.cs:135-140), then does the dual-fetch: an untracked,
no-change-tracking query to verify the password cheaply (AuthenticationServiceBase.cs:148,
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:195-204). The email is
normalized through the Email value object before the
query so the EF predicate compares same-typed converted values
(AuthenticationServiceBase.cs:144). Soft-deleted accounts fall out through EF global query filters
and return the same generic 401 as a wrong password (AuthenticationServiceBase.cs:146-166), and so
does an account with no stored credential at all (the external-OAuth shape), which also pays one
throwaway key derivation so its 401 does not come back measurably faster than a real check
(AuthenticationServiceBase.cs:150-159, AuthenticationServiceBase.cs:615-625): the API never
reveals whether an email exists. The app's candidate gate, the email-confirmation gate and the
second-factor challenge all run after the password check (AuthenticationServiceBase.cs:168-193),
and a successful login clears the attempt counters (AuthenticationServiceBase.cs:207) before
handing off to the shared token-issue path (AuthenticationServiceBase.cs:212).
RegisterAsync (AuthenticationServiceBase.cs:221) first refuses a request that did not accept the
Terms of Service when the host configures a current terms version (AuthenticationServiceBase.cs:236-239,
covered in the Terms of Service section below), then 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:241-305). 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:266-294); the shared failure
factory keeps the two paths indistinguishable to the caller (AuthenticationServiceBase.cs:632). 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:288). The password rule a registration is validated against is
defined exactly once, in PasswordComplexity
(MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/PasswordComplexity.cs:18): 8 to 128 UTF-16 code
units (PasswordComplexity.cs:21, PasswordComplexity.cs:24) with an uppercase letter, a lowercase
letter, a digit and a special character, each a Unicode-aware generated regex with a one-second match
timeout, so an accented or CJK letter counts as a letter and never as the special character
(PasswordComplexity.cs:12-16, PasswordComplexity.cs:27-43), combined in Evaluate
(PasswordComplexity.cs:52-57). It lives in Shared because both sides read it: the server's
StrongPasswordRules matches the same four patterns and the same ceiling
(MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:197-201), and
the client form attribute calls Evaluate
(MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/PasswordComplexityAttribute.cs:24), so the
two cannot give different verdicts for one input, which is the validation-parity point of rubric §24
the type's own remarks cite (PasswordComplexity.cs:5-10).
RefreshTokenAsync (AuthenticationServiceBase.cs:309) extracts claims from the expired access
token (signature still verified, only lifetime skipped, AuthenticationServiceBase.cs:321-323), reads
the identifier off sub through ClaimsPrincipalExtensions
(AuthenticationServiceBase.cs:333), carries the mfa claim the user already earned across the
rotation so a signed-in session is not quietly demoted every fifteen minutes
(AuthenticationServiceBase.cs:353-358), and then hands the presented refresh token to the issuer's
RotateAsync (AuthenticationServiceBase.cs:363-369). 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:24), 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:35), 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:129-162), and HashToken is an
unsalted, deterministic SHA-256 rendered as 64 upper-case hex characters precisely because lookups are
by hash (RefreshSession.cs:177-181, width constant at RefreshSession.cs:46). 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:168-174). 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:26-35). Revoke is
idempotent by refusal, so the first reason and instant recorded are the ones kept
(RefreshSession.cs:191-206), and the four reason constants (Rotated, SignedOut, ReuseDetected,
SessionCapExceeded, RefreshSession.cs:58-67) 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:91), so presenting an
already-rotated token lands on a revoked row rather than on nothing: that is the
ADR-050 reuse signal, and
AuthSessionIssuer answers it by revoking every live session the user holds
(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Sessions/AuthSessionIssuer.cs:335-339,
AuthSessionIssuer.cs:489-501). The three
rejections behind the single generic error are deliberately different in what they do
(AuthSessionIssuer.cs:284-345): 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 (AuthSessionIssuer.cs:313-316); a
revoked row splits by why it was revoked, so one rotated away (or already flagged as reuse) is the
reuse signal and revokes the family, while one signed out or evicted by the session cap only lost its
session and fails alone (AuthSessionIssuer.cs:318-326, classified by IsReuseSignal at
AuthSessionIssuer.cs:462-470, which treats a missing or unrecognized reason as reuse); an expired
row is an ordinary end of life, so that device re-authenticates while the others keep working
(AuthSessionIssuer.cs:342-344). One window softens the reuse answer: a row revoked as Rotated
less than RefreshSessionSettings.ReuseGraceSeconds ago
(AuthSessionIssuer.cs:479-487) is a sibling race (a second tab, or one browser served by two
replicas), answered 409 Conflict with the code Auth.RefreshSuperseded and nothing revoked
(AuthSessionIssuer.cs:328-333, AuthSessionIssuer.cs:278-280). Two requests presenting the same
still-live token go through the same classification: rotation is claimed atomically through
IRefreshSessionStore.TryRotateAsync (AuthSessionIssuer.cs:417-419), the
request that loses the claim re-reads the row as the database holds it now
(AuthSessionIssuer.cs:427-429), and then a sign-out or cap eviction fails it alone, a rotation
inside the grace is a 409, and anything else is answered exactly like a replay
(AuthSessionIssuer.cs:430-450).
IRefreshSessionStore
(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/IRefreshSessionStore.cs:22) is the narrow
persistence port: add, find by hash (revoked and expired rows included, which is load-bearing for
reuse detection, IRefreshSessionStore.cs:29-38), list a user's un-revoked sessions
(IRefreshSessionStore.cs:46), 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:50-63),
re-read one session by id untracked for the rotation loser above (IRefreshSessionStore.cs:66-81,
whose default body returns null), save, and TryRotateAsync (IRefreshSessionStore.cs:116).
Implementations must return tracked instances from every lookup except that read-only re-read,
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-21). 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:121-146).
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:166-172).
The workflow around the port lives in AuthSessionIssuer
(AuthSessionIssuer.cs:41), not in the authentication base, and is small and worth reading end to
end. IssueTokensAsync (AuthenticationServiceBase.cs:443) hands the user's id and a minting callback
to the issuer's IssueAsync (AuthSessionIssuer.cs:64), which 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 (AuthSessionIssuer.cs:75-88). OpenSessionAsync (AuthSessionIssuer.cs:352) mints the
refresh token, builds the row, and, before staging the insert, enforces the per-user cap by revoking
the oldest live sessions (AuthSessionIssuer.cs:374, AuthSessionIssuer.cs:503-525), so one account
cannot grow the table without bound while a legitimate sign-in never fails. OpenSessionAsync and its
rotation twin RotateSessionAsync (AuthSessionIssuer.cs:394) both return
IssuedSession (AuthSessionIssuer.cs:532), 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
(AuthSessionIssuer.cs:544), a private pass-through ITokenService that the issuer exposes as its
TokenService (AuthSessionIssuer.cs:52-55) and arms through MintForSession for the duration of the
app's CreateAccessToken call (AuthSessionIssuer.cs:132-147, reached from
AuthenticationServiceBase.cs:495-496). 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:412) delegates to the
issuer's ListActiveAsync (AuthSessionIssuer.cs:193), which 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 session id the caller passes from the token's
sid (AuthSessionIssuer.cs:201-214); 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:428, delegating to AuthSessionIssuer.cs:231) 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 (AuthSessionIssuer.cs:246-253), while
RevokeTokenAsync (AuthenticationServiceBase.cs:378, delegating to AuthSessionIssuer.cs:150)
degrades to signing every device out when the presented token does not identify a live session of
this user's (AuthSessionIssuer.cs:160-174). Those methods
surface on IAuthenticationService (IAuthenticationService.cs:67,
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:152,
AuthControllerBase.cs:183, AuthControllerBase.cs:231).
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). ReuseGraceSeconds (default 10, range 0 to 300,
RefreshSessionSettings.cs:97-98) is the rotation-race window described above, matched to the cookie
refresher's rotation grace, and its remarks state the trade-off plainly: a stolen-token replay arriving
inside the window of a legitimate rotation is answered 409 instead of revoking the family, and 0
treats every returning rotated token as reuse (RefreshSessionSettings.cs:82-95). 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:178-186), 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:34),
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:41),
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:64) 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:68-74). 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:28) 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:45-47), so User@x.com, user@x.com, and a padded variant collapse onto
one lockout instead of handing an attacker three independent budgets. The normalization itself lives
once, in the internal EmailIdentity helper
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/EmailIdentity.cs:12), which every
cache-keyed auth service calls: a valid address becomes the Email value object's normalized value,
and a malformed one (which never matches a user but can still mint a key) falls back to the same
trim-and-lowercase shape so its attempts land on one key too (EmailIdentity.cs:22-30). 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:102), and it rate-limits
registrations per source IP (default 10 per 60-minute window, LoginProtectionSettings.cs:37-43,
LoginProtectionService.cs:127-181). 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:78-86). Sequential guessing, which
is what a credential-stuffing run looks like, still trips the lockout.
The second factor: one optional step in the same sign-in
Two-factor authentication ships as contracts plus a stateless implementation, never as a feature that
turns itself on: the account row belongs to the app, and making a second factor mandatory would be a
behavior change in a deployed system
(ADR-116). The
cryptography lives in ITwoFactorService
(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/TwoFactor/ITwoFactorService.cs:16) and its
implementation TotpTwoFactorService
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/TwoFactor/TotpTwoFactorService.cs:35): mint
a Base32 shared secret (TotpTwoFactorService.cs:40-41), render the otpauth://totp/... provisioning
URI an authenticator app is keyed from (TotpTwoFactorService.cs:44, TotpTwoFactorService.cs:54),
verify a code inside the configured skew window (TotpTwoFactorService.cs:58), and generate and match
single-use recovery codes (TotpTwoFactorService.cs:91, TotpTwoFactorService.cs:120). It is
deliberately stateless and persistence-free, so a data migration enrolling existing accounts can call
it directly and every method is testable against a fixed clock (ITwoFactorService.cs:10-15). Recovery
codes travel as a RecoveryCodeSet (ITwoFactorService.cs:97), plaintext and
hashes paired positionally so a caller cannot store one set and display another, and the plaintext
exists only in the returned value. Matching compares in fixed time through
CryptographicOperations.FixedTimeEquals with no early exit (TotpTwoFactorService.cs:142), and the
codes are Base32 rather than Base64 because that alphabet has no case ambiguity for a user reading one
off paper (TotpTwoFactorService.cs:98-100).
TwoFactorSettings
(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/TwoFactor/TwoFactorSettings.cs:15) binds from
Authentication:TwoFactor (TwoFactorSettings.cs:18) and defaults to the RFC 6238 values every
authenticator app assumes: six digits (TwoFactorSettings.cs:30), a thirty-second step
(TwoFactorSettings.cs:34), one step of tolerated skew on each side, which widens the accepted window
to roughly ninety seconds (TwoFactorSettings.cs:46), a twenty-byte secret
(TwoFactorSettings.cs:61), and ten recovery codes of ten random bytes each
(TwoFactorSettings.cs:50, TwoFactorSettings.cs:57). They are configurable so a host can match an
existing enrollment base, not because moving them is a good idea: an app keyed against one period
cannot read codes minted with another (TwoFactorSettings.cs:9-14).
State is the app's, so it is reached through two contracts rather than a framework table.
ITwoFactorUserState
(MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/ITwoFactorUserState.cs:27) is what the app's User
exposes: the enabled flag, the Base32 secret, and the unspent recovery-code hashes
(ITwoFactorUserState.cs:30, ITwoFactorUserState.cs:36, ITwoFactorUserState.cs:42). The
load-bearing invariant is stated on the interface itself: a stored secret is not an enabled second
factor, because starting an enrollment writes the secret while the flag stays false, so an enrollment
the user abandons half way can never gate a later sign-in (ITwoFactorUserState.cs:22-25).
ITwoFactorStore
(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/TwoFactor/ITwoFactorStore.cs:24) is the matching
persistence port, split along that invariant: StartEnrollmentAsync stores the secret without
activating anything (ITwoFactorStore.cs:44), CompleteEnrollmentAsync activates it and stores the
first recovery hashes only after a code minted from that secret has verified (ITwoFactorStore.cs:54),
DisableAsync clears both so a later enrollment starts from a fresh secret rather than reviving an old
one (ITwoFactorStore.cs:66), and the recovery set is replaced wholesale or spent one code at a time
(ITwoFactorStore.cs:76, ITwoFactorStore.cs:93). Every mutating member returns a
Result so an aggregate that refuses can say so without an
exception crossing the layer, and implementations save their own work, because a workflow treats one
call's result as final (ITwoFactorStore.cs:13-22). The framework ships no EF implementation here the
way it does for refresh sessions, because the secret and the hashes belong to the app's user row
(ITwoFactorStore.cs:6-10).
The sign-in hook is a third contract, ITwoFactorAuthenticator
(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/TwoFactor/ITwoFactorAuthenticator.cs:24), and it
is a separate collaborator on purpose:
AuthenticationServiceBase<TUser> takes it as an optional
constructor dependency, so an app that has not adopted the second factor passes nothing and its
sign-in does not even pay a query (ITwoFactorAuthenticator.cs:13-16). The challenge runs after the
password check, for the same reason the account-state gate does: reaching it proves the caller owns the
credential, so answering "this account needs a second factor" tells the owner something rather than
telling an address sweeper which accounts are protected (ITwoFactorAuthenticator.cs:19-21). The
shipped TwoFactorAuthenticator
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/TwoFactor/TwoFactorAuthenticator.cs:36) reads
the state and returns TwoFactorOutcome.NotEnrolled when there is no active
factor, so an unenrolled account's sign-in is unchanged (TwoFactorAuthenticator.cs:114,
ITwoFactorAuthenticator.cs:47); otherwise it verifies a TOTP code (TwoFactorAuthenticator.cs:114),
falls back to matching and spending a recovery code (TwoFactorAuthenticator.cs:70,
TwoFactorAuthenticator.cs:85), and fails with one of the two TwoFactorErrors
codes: Authentication.TwoFactorRequired when a code was needed and none arrived,
Authentication.TwoFactorInvalid when one arrived and did not verify
(TwoFactorAuthenticator.cs:61, TwoFactorAuthenticator.cs:74,
MMCA.Common/Source/Core/MMCA.Common.Application/Auth/TwoFactor/TwoFactorErrors.cs:18,
TwoFactorErrors.cs:21). Which method satisfied the challenge is what the issued token records:
AuthClaimTypes names the mfa claim and its two values, otp and recovery
(MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/AuthClaimTypes.cs:62, AuthClaimTypes.cs:65,
AuthClaimTypes.cs:68), read back through
ClaimsPrincipalExtensions.HasMultiFactor and FindMultiFactorMethod
(ClaimsPrincipalExtensions.cs:127, ClaimsPrincipalExtensions.cs:137), which is how a presence check
stays out of ICurrentUserService. The wire shapes are
TwoFactorCodeRequest with its
TwoFactorCodeRequestValidator,
TwoFactorSetupResponse and
TwoFactorRecoveryCodesResponse.
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:33), 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:53,PasswordResetTokenService.cs:90), 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:57-58,PasswordResetTokenService.cs:84-90), so a cache dump hands out no working reset links, and the comparison on redemption is constant time throughCryptographicOperations.FixedTimeEquals(PasswordResetTokenService.cs:146). - An attempt cap. A wrong token increments a counter on the record, and the record is discarded at
MaxValidationAttempts(PasswordResetTokenService.cs:160-181, 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:174-180), 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:68-79, default 3 per 60 minutes,PasswordResetSettings.cs:40,PasswordResetSettings.cs:44), and a successful redemption deletes the token and that counter (PasswordResetTokenService.cs:154-155) so a legitimate reset does not leave the user throttled out of a later one.
Keys are built from an Email-normalized identity, through the same
EmailIdentity helper and for the same reason
LoginProtectionService does it
(PasswordResetTokenService.cs:47-55). The cached record, PasswordResetEntry
(PasswordResetTokenService.cs:199), 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:190-194). Token material is 32 random bytes, Base64Url
encoded (PasswordResetTokenService.cs:39, PasswordResetTokenService.cs:81), redeemable for
TokenLifetimeMinutes (default 30, PasswordResetSettings.cs:29), and every rejection (unknown,
expired, mismatched, attempt-capped) collapses into one generic failure
(PasswordResetTokenService.cs:183-187). 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:160-164).
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:41)
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:73-83), hashes through IPasswordHasher
and writes the credential through the aggregate's ChangePassword
(ResetPasswordHandlerBase.cs:94-95), then clears the login-protection counters so a user who reset
because of a lockout is not left locked out (ResetPasswordHandlerBase.cs:110).
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).
Email confirmation: the same token shape, a different prefix
Confirming an address is the forgot-password flow's twin, and it is built that way on purpose: member
for member, IEmailConfirmationTokenService
(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/EmailConfirmation/IEmailConfirmationTokenService.cs:15)
mirrors the reset port, with IssueAsync minting a token for an address and account
(IEmailConfirmationTokenService.cs:28) and ValidateAndConsumeAsync redeeming it exactly once and
returning the confirmed account's identifier (IEmailConfirmationTokenService.cs:41). The
implementation EmailConfirmationTokenService
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/EmailConfirmationTokenService.cs:36) rides on
ICacheService under its own key prefix and buys the same four
properties: 32 random bytes Base64Url encoded (EmailConfirmationTokenService.cs:41,
EmailConfirmationTokenService.cs:66), only the token's SHA-256 stored
(EmailConfirmationTokenService.cs:135), a constant-time comparison on redemption
(EmailConfirmationTokenService.cs:108), and every rejection collapsed into one generic failure
(EmailConfirmationTokenService.cs:160-162). Its token and request-counter keys go through the same
EmailIdentity normalization as the reset keys
(EmailConfirmationTokenService.cs:130, EmailConfirmationTokenService.cs:132). The cached record is
EmailConfirmationEntry (EmailConfirmationTokenService.cs:174), JSON
primitives only for the same round-trip reason PasswordResetEntry is.
EmailConfirmationSettings
(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/EmailConfirmation/EmailConfirmationSettings.cs:10)
binds from Authentication:EmailConfirmation (EmailConfirmationSettings.cs:13) and differs from the
reset settings where the use case does: the token is redeemable for a day rather than half an hour
(TokenLifetimeMinutes default 1440, EmailConfirmationSettings.cs:29), because a confirmation mail
is commonly opened much later, while the attempt cap (5, EmailConfirmationSettings.cs:36) and the
per-address request throttle (3 per 60 minutes, EmailConfirmationSettings.cs:40,
EmailConfirmationSettings.cs:44) match. The gate is RequireConfirmedEmail
(EmailConfirmationSettings.cs:55), off unless a host sets it and the app's user implements
IEmailConfirmableUser
(MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IEmailConfirmableUser.cs:22), a two-member contract:
the IsEmailConfirmed flag and a Result-returning ConfirmEmail() that writes it through the
aggregate (IEmailConfirmableUser.cs:25, IEmailConfirmableUser.cs:33). A user type that does not
implement the interface is never asked about confirmation (IEmailConfirmableUser.cs:12), which is
what keeps the feature inert for the apps that have not adopted it. The two failures are named in
EmailConfirmationErrors
(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/EmailConfirmation/EmailConfirmationErrors.cs:9):
Authentication.EmailNotConfirmed for a sign-in blocked by the gate and
Authentication.InvalidConfirmationToken for every rejected redemption
(EmailConfirmationErrors.cs:12, EmailConfirmationErrors.cs:15, EmailConfirmationErrors.cs:25,
EmailConfirmationErrors.cs:33). The requests are
SendEmailConfirmationRequest and
ConfirmEmailRequest, guarded by
SendEmailConfirmationRequestValidator and
ConfirmEmailRequestValidator. A sibling constant class,
AuthErrorCodes
(MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/AuthErrorCodes.cs:12), names the authentication
outcomes a client has to branch on rather than merely display: Auth.EmailAlreadyExists
(AuthErrorCodes.cs:19), returned identically by the registration pre-check and by the unique-index
race recovery so the two paths stay indistinguishable, and Auth.TermsNotAccepted
(AuthErrorCodes.cs:25), returned only when a current terms version is configured (next section).
Terms of Service acceptance: one version string, one rule set
Recording consent to a host's Terms of Service is opt-in the same way, and unset is the off switch.
LegalAcceptanceOptions
(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Legal/LegalAcceptanceOptions.cs:12) binds from
the Legal section (LegalAcceptanceOptions.cs:15) through AddLegalAcceptance(configuration)
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Auth.cs:104-110) and carries a
single value, CurrentTermsVersion (LegalAcceptanceOptions.cs:21). With no version configured,
registration does not ask, the read endpoint reports every user as current, and the accept endpoint
has nothing to accept (LegalAcceptanceOptions.cs:7-11); changing the version asks every signed-in
user to accept again (LegalAcceptanceOptions.cs:17-20).
The rules live once, in LegalAcceptancePolicy
(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Legal/LegalAcceptancePolicy.cs:10), so the
registration flow and the API controller base cannot apply them differently. ResolveCurrentVersion
trims the configured value and treats null or whitespace as "none" (LegalAcceptancePolicy.cs:18-22).
EnsureAcceptsCurrentVersion accepts only an ordinal match against a configured version
(LegalAcceptancePolicy.cs:33-36), so a dialog left open across a version bump cannot record consent
to text the user never saw (the client echoes the version it showed in
AcceptLegalTermsRequest,
MMCA.Common/Source/Core/MMCA.Common.Shared/Legal/AcceptLegalTermsRequest.cs:6-12). Normalize
re-derives the standing from the accepted version and instant alone, so the answer never depends on a
consumer filling the computed fields correctly (LegalAcceptancePolicy.cs:47-52). The standing is
LegalAcceptanceDTO
(MMCA.Common/Source/Core/MMCA.Common.Shared/Legal/LegalAcceptanceDTO.cs:18): current version,
accepted version, accepted instant and IsCurrent (LegalAcceptanceDTO.cs:21, LegalAcceptanceDTO.cs:24,
LegalAcceptanceDTO.cs:27, LegalAcceptanceDTO.cs:33), built through Evaluate, where an
unconfigured version always yields IsCurrent = true so a client never blocks a user on a host that
has not opted in (LegalAcceptanceDTO.cs:42-53, LegalAcceptanceDTO.cs:9-11).
Registration reads the version without a new constructor dependency:
AuthenticationValidators takes the options as an optional parameter and
exposes CurrentTermsVersion (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationValidators.cs:28,
AuthenticationValidators.cs:43), which
AuthenticationServiceBase<TUser> surfaces to its subclasses
(AuthenticationServiceBase.cs:120). When a version is configured and the plain AcceptedTerms flag
on RegisterRequest
(MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Requests/RegisterRequest.cs:26) is false,
RegisterAsync fails before the rate limit with
LegalAcceptanceErrors.TermsNotAccepted
(AuthenticationServiceBase.cs:236-239,
MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Legal/LegalAcceptanceErrors.cs:22-25); it is a
flag rather than a version because the anonymous register page cannot read the version
(AuthenticationServiceBase.cs:233-235). The app's CreateUser override then stamps the configured
version on a user that implements ILegalAcceptingUser
(AuthenticationServiceBase.cs:112-115), a Domain contract of two read-only properties and a
Result-returning AcceptTerms that should treat the version already on record as an ordinary repeat
(MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/ILegalAcceptingUser.cs:21,
ILegalAcceptingUser.cs:24, ILegalAcceptingUser.cs:27, ILegalAcceptingUser.cs:29-37). A user
created outside RegisterAsync (the external-login path) is left unstamped and asked by the UI's
acceptance gate on first sign-in (AuthenticationServiceBase.cs:116-117).
After registration the standing is read and recorded through
ILegalAcceptanceService
(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Legal/ILegalAcceptanceService.cs:21), which each
app implements over its own User (GetForCurrentUserAsync and AcceptForCurrentUserAsync,
ILegalAcceptanceService.cs:28, ILegalAcceptanceService.cs:41). The version rules are explicitly not
the implementation's job (ILegalAcceptanceService.cs:11-20):
LegalAcceptanceControllerBase
resolves the version, refuses an acceptance of any other version before the service is called, and
normalizes every answer on the way out
(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Legal/LegalAcceptanceControllerBase.cs:54,
LegalAcceptanceControllerBase.cs:110, LegalAcceptanceControllerBase.cs:83,
LegalAcceptanceControllerBase.cs:125). LegalAcceptanceRoutes
(MMCA.Common/Source/Core/MMCA.Common.Shared/Legal/LegalAcceptanceRoutes.cs:7) declares the action
template me/legal-acceptance and the full Users/me/legal-acceptance path the UI client calls, so
the controller base and the client cannot drift (LegalAcceptanceRoutes.cs:13,
LegalAcceptanceRoutes.cs:16). The two failure codes live where their callers can reach them:
Auth.TermsNotAccepted on AuthErrorCodes because it is a registration outcome,
and Legal.VersionNotCurrent on LegalAcceptanceErrorCodes
(MMCA.Common/Source/Core/MMCA.Common.Shared/Legal/LegalAcceptanceErrorCodes.cs:7,
LegalAcceptanceErrorCodes.cs:13), with LegalAcceptanceErrors aliasing both and building each as a
validation error (LegalAcceptanceErrors.cs:14, LegalAcceptanceErrors.cs:17,
LegalAcceptanceErrors.cs:33-36). Versioned, dated consent that a host can re-ask on a terms change is
the [Rubric §30, Compliance/Privacy/Data Governance] reading of this cluster.
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:109-113). 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:24) used by the
authorization model below, plus the standard sub (AuthClaimTypes.cs:34) and sid
(AuthClaimTypes.cs:47), 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:14,
AuthorizationExtensions.cs:62) wires the whole mechanism: the permission handler and the on-demand
policy provider (AuthorizationExtensions.cs:71-73), the shared registry, built once and exposed
under both IPermissionRegistry and
IPermissionCatalog (AuthorizationExtensions.cs:128-129,
AuthorizationExtensions.cs:135-136), and the fallback policy described at the end of this section.
Role names still exist as data, but the framework ships no constant class naming them: roles get a
value-object base, RoleValue
(MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/RoleValue.cs:26), so each app can fix its own role
set with case-insensitive, type-guarded equality (RoleValue.cs:91-97), a frozen interned lookup
(RoleValue.cs:76-85), and Result-returning validation (RoleValue.cs:43) 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:13),
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-32,
MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Permissions/IPermissionRegistry.cs:14), reading the
caller's roles through ClaimsPrincipalExtensions.GetRoleValues
(PermissionAuthorizationHandler.cs:30,
MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/ClaimsPrincipalExtensions.cs:59), which is the one
place the three claim-type spellings the JWT middleware may produce are reconciled. The registry itself
(PermissionRegistry,
MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Permissions/PermissionRegistry.cs:16) is an immutable, frozen
role-to-permission map with case-insensitive role keys and ordinal permission values
(PermissionRegistry.cs:33-36) 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:105), duplicate grants union rather than collide
(PermissionRegistryBuilder.cs:34), and the shared registry is built lazily on first resolve, after
every module has registered (AuthorizationExtensions.cs:129, PermissionRegistryBuilder.cs:46). The
same instance answers as IPermissionCatalog
(PermissionRegistry.cs:16, PermissionRegistry.cs:55, PermissionRegistry.cs:58), explicitly
implemented because a catalog's flat list of permission names and the registry's per-role lookup are
different questions that would otherwise share a member name. 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:123). It grants nothing, so a
permission-gated request is denied rather than allowed (UnconfiguredPermissionRegistry.cs:37,
UnconfiguredPermissionRegistry.cs:44), and it says so exactly once, through an interlocked one-shot
flag, in a log message naming the call that would fix it (UnconfiguredPermissionRegistry.cs:25,
UnconfiguredPermissionRegistry.cs:60, UnconfiguredPermissionRegistry.cs:68). 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:13-19). 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:13) whose
defaults preserve the original customer_id / Admin / id behavior
(OwnerOrAdminFilterOptions.cs:16-31,
ADR-033), with
OwnershipHelper
(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnershipHelper.cs:11) supplying both
the bypass-role check (OwnershipHelper.cs:18) and the query-scoping specification factory that
controllers use to narrow collection endpoints to the caller's own rows
(OwnershipHelper.cs:35-68).
Compiled grants are the baseline, not the ceiling. A host that needs an operator to move a capability
between roles without a redeploy calls AddStoredPermissionGrants(configuration)
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Auth.cs:139), which binds
PermissionGrantSettings
(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Permissions/PermissionGrantSettings.cs:12,
bound at DependencyInjection.Auth.cs:141-144), registers the EF-backed
IPermissionGrantStore (DependencyInjection.Auth.cs:149), and decorates the
compiled registry with LayeredPermissionRegistry
(DependencyInjection.Auth.cs:170-181). The layering rule is a union with no deny row: a role holds a
permission when either the compiled registry or the stored grants say so
(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Permissions/LayeredPermissionRegistry.cs:30,
LayeredPermissionRegistry.cs:55-65), so a bad edit can add a capability but can never take away one
an endpoint's own module declared. Because HasPermission is synchronous, the stored half cannot be
read from the database on the hot path: IPermissionGrantCache
(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Permissions/IPermissionGrantCache.cs:21) is the
boundary over an in-memory snapshot, refreshed on demand (IPermissionGrantCache.cs:37,
IPermissionGrantCache.cs:11-19), and
IPermissionGrantCacheInvalidator (IPermissionGrantCache.cs:53,
IPermissionGrantCache.cs:61) is what an edit calls. The consequence a host accepts is that a grant
edit reaches other replicas within CacheSeconds (default 300, PermissionGrantSettings.cs:25), with
DataSourceName naming the one database that carries the rows (PermissionGrantSettings.cs:33),
exactly as RefreshSessionSettings does. The row itself,
PermissionGrant
(MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/PermissionGrant.cs:24), is a two-column identity
(PermissionGrant.cs:39, PermissionGrant.cs:45) trimmed and length-validated in a
Result-returning factory (PermissionGrant.cs:64, widths at PermissionGrant.cs:27 and
PermissionGrant.cs:30), and its table is mapped only for the host that opted in, through a model gate
whose mere registration is the opt-in the context reads (DependencyInjection.Auth.cs:147).
The administration API sits on top of the same grants.
IRoleAdministrationService
(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Administration/IRoleAdministrationService.cs:29)
lists roles, reads one role, returns the catalog of role and permission names, and replaces a role's
stored grants (IRoleAdministrationService.cs:37, IRoleAdministrationService.cs:45,
IRoleAdministrationService.cs:58, IRoleAdministrationService.cs:72); it ships filled in as
StoredPermissionRoleAdministrationService
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/Administration/StoredPermissionRoleAdministrationService.cs:45,
its four methods at :54, :80, :93, :114) because both halves of that surface are
framework-owned. The user half cannot be:
IUserAdministrationService<TUserDto>
(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Administration/IUserAdministrationService.cs:24)
is generic in the app's own DTO, constrained by the marker IUserAdminDTO
(MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Administration/IUserAdminDTO.cs:15), and each app
implements listing, reading, locking and role assignment over its own User
(IUserAdministrationService.cs:32, IUserAdministrationService.cs:42,
IUserAdministrationService.cs:57, IUserAdministrationService.cs:66), with
UserAdministrationQuery (IUserAdministrationService.cs:79) carrying the
paging and filter arguments. The two capabilities that gate the whole surface are named once in
AdministrationPermissions
(MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Permissions/AdministrationPermissions.cs:15):
users:manage and roles:manage (AdministrationPermissions.cs:18, AdministrationPermissions.cs:21).
The wire shapes are SetUserRolesRequest,
SetRolePermissionsRequest,
RolePermissionsResponse and
PermissionCatalogResponse, each guarded by its own FluentValidation
rule (SetUserRolesRequestValidator,
SetRolePermissionsRequestValidator).
The last piece of the authorization wiring is defensive rather than declarative.
AddAuthorizationPolicies() installs a fallback policy, so an endpoint carrying no authorization
metadata at all is denied rather than served. The requirement is its own type,
FallbackAuthorizationRequirement
(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/Fallback/FallbackAuthorizationRequirement.cs:16),
and FallbackAuthorizationHandler
(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/Fallback/FallbackAuthorizationHandler.cs:32)
grants it to an authenticated caller, to a request whose path matches one of the exempt prefixes, to
a request that matched no endpoint and names no web-root file (so the host's not-found page renders a
404 instead of a sign-in redirect), and to a request routed to an API-versioning rejection endpoint
(so the caller gets the 400 version error rather than a 401 challenge)
(FallbackAuthorizationHandler.cs:46-51, rationale at FallbackAuthorizationHandler.cs:16-28). A bare RequireAuthenticatedUser() would not do, because a
Blazor host maps endpoint-routed static and framework surfaces that carry no metadata of their own and
would be gated along with everything else (FallbackAuthorizationRequirement.cs:11-15), which is why
the requirement is path-aware. FallbackAuthorizationOptions
(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/Fallback/FallbackAuthorizationOptions.cs:14)
carries the Enabled switch (on by default, FallbackAuthorizationOptions.cs:52) and the editable
exempt-prefix list seeded from the framework defaults (FallbackAuthorizationOptions.cs:64); the
handler is registered with TryAddEnumerable and the policy attached only while the switch is set
(AuthorizationExtensions.cs:82-83, AuthorizationExtensions.cs:88-91).
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:18-19)
seeded and cleared from JS through SessionCookieEndpoints
(SessionCookieEndpoints.cs:16, SessionCookieEndpoints.cs:35-45, request body
SessionCookieRequest at SessionCookieEndpoints.cs:95), written with one
shared set of attributes by SessionCookieJar
(MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionCookieJar.cs:14: HttpOnly,
Secure outside Development, Path=/, SameSite from
SessionCookieSettings, Lax by default and Strict on the same-origin API
proxy, MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionCookieSettings.cs:19, and
a max age resolved from SessionCookieSettings.Lifetime, else the configured refresh-token lifetime,
else 7 days, because a cookie must not outlive the credential it carries,
SessionCookieJar.cs:30-48, SessionCookieJar.cs:61-67, SessionCookieSettings.cs: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:75) through
the ICookieSessionRefresher port (CookieSessionRefresher.cs:32). The
refresher first tries to read a still-valid expiry out of the access cookie with a 30-second skew
allowance (CookieSessionRefresher.cs:85, CookieSessionRefresher.cs:243-272); failing that it
exchanges the refresh cookie at the API's auth/refresh endpoint server-to-server
(CookieSessionRefresher.cs:186-192), so the refresh token never reaches browser JS. That call carries
the browser's user-agent and IP, captured from the triggering request by the private
BrowserOrigin before the lock is taken, so the rotated session records the real
device instead of this host's empty user-agent and loopback address (CookieSessionRefresher.cs:136,
CookieSessionRefresher.cs:190, CookieSessionRefresher.cs:283-297). It then writes
the rotated pair back as cookies and stashes the fresh access token on HttpContext.Items
(CookieSessionRefresher.cs:143-147) 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:86, CookieSessionRefresher.cs:88, CookieSessionRefresher.cs:151-168,
CookieSessionRefresher.cs:208), 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:68-73); two unrelated tokens sharing a stripe is harmless
because the grace cache is re-checked per token after acquiring
(CookieSessionRefresher.cs:161-165). Failures are kept apart as a
SessionRefreshOutcome carrying a
SessionRefreshStatus: only a 400, 401 or 403 from the identity endpoint is
Rejected (the session is over), while anything else, including the 409 Auth.RefreshSuperseded
answer to a rotation race, is Unavailable, so a caller that clears cookies on failure does so only
for a session that is really dead (CookieSessionRefresher.cs:114-126, CookieSessionRefresher.cs:41-49).
A transport failure, including the resilience pipeline's own refusals, is not cached and renders the
request anonymously rather than throwing a 500 out of SSR (CookieSessionRefresher.cs:175-181,
CookieSessionRefresher.cs:211-219). The same refresher backs the same-origin
POST /auth/session/token endpoint the browser polls to hydrate its in-memory token
(SessionCookieEndpoints.cs:60-82), guarded by the cookies' configured SameSite plus a
Sec-Fetch-Site cross-site rejection (SessionCookieEndpoints.cs:63-66, SessionCookieEndpoints.cs:88-93);
a host that sets ClaimsOnlyBrowserTokens hands the browser an unsigned copy of the claims built by
SessionClaimsToken rather than the credential
(SessionCookieEndpoints.cs:74-77, SessionCookieSettings.cs:29). The endpoint returns
SessionTokenResponse (CookieSessionRefresher.cs:23), the browser-safe
projection of the internal SessionTokenResult
(CookieSessionRefresher.cs:17) 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:8) holds the single flag name
Privacy.DataExport (PrivacyFeatures.cs:12) 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:88),
the IdempotencyFilter
(MMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyFilter.cs:91),
CachingQueryDecorator<TQuery, TResult>
(MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/CachingQueryDecorator.cs:250),
MemoryCacheService
(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/MemoryCacheService.cs:45), and the
default GetOrCreateAsync lock table on
ICacheService
(MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/ICacheService.cs:199).
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 further Shared clusters land here the same way. The feature-flag lifecycle metadata closes the
flag-debt trade-off of
ADR-031:
FeatureFlagAttribute
(MMCA.Common/Source/Core/MMCA.Common.Shared/FeatureFlags/FeatureFlagAttribute.cs:32) is applied to the
public const string fields of a *Features class (FeatureFlagAttribute.cs:9,
FeatureFlagAttribute.cs:31) and carries a FeatureFlagLifetime
(MMCA.Common/Source/Core/MMCA.Common.Shared/FeatureFlags/FeatureFlagLifetime.cs:9), an optional
RemoveBy date in a fixed yyyy-MM-dd format, and an owner (FeatureFlagAttribute.cs:35,
FeatureFlagAttribute.cs:38, FeatureFlagAttribute.cs:46, FeatureFlagAttribute.cs:52). The
distinction is the whole point: a Permanent flag is a capability a host chooses to run with or
without and must not carry a removal date, while a Temporary one exists only until a migration or
experiment finishes, must carry one, and fails the build once that date has passed
(FeatureFlagLifetime.cs:11-20). FeatureFlagRegistry
(MMCA.Common/Source/Core/MMCA.Common.Shared/FeatureFlags/FeatureFlagRegistry.cs:35) is the runtime
half, reflecting over assemblies to project every declared flag into a
FeatureFlagDescriptor (FeatureFlagRegistry.cs:17,
FeatureFlagRegistry.cs:46, FeatureFlagRegistry.cs:64) with the two recognizers the fitness rules
also use (FeatureFlagRegistry.cs:77, FeatureFlagRegistry.cs:91).
PrivacyFeatures above is one of the framework's own annotated flags.
The strongly typed identifier capability is the opt-in wrapper struct of
ADR-115, shipped
complete and adopted by nothing, with the
ADR-048 primitive
aliases left as the default. IStronglyTypedId<TSelf, TValue>
(MMCA.Common/Source/Core/MMCA.Common.Shared/Identifiers/IStronglyTypedId.cs:60) is two members, a
Value getter and a static abstract From (IStronglyTypedId.cs:65, IStronglyTypedId.cs:72), so a
wrapper is a positional readonly record struct plus one factory line; IParsable<TSelf> arrives as
explicit default implementations, which is the only form an inherited static abstract member allows,
so OrderId.Parse(...) does not compile and every framework boundary parses through the interface
(IStronglyTypedId.cs:25-36). The static helper StronglyTypedId
(MMCA.Common/Source/Core/MMCA.Common.Shared/Identifiers/StronglyTypedId.cs:19) is what those
boundaries call: Parse/TryParse (StronglyTypedId.cs:40, StronglyTypedId.cs:63), the
CanParseValues and GetValueType/IsStronglyTypedId reflection probes (StronglyTypedId.cs:85,
StronglyTypedId.cs:97, StronglyTypedId.cs:108), and TryDescribe (StronglyTypedId.cs:118), with
the per-primitive delegate cache
StronglyTypedIdValueParser<TValue> and its
StronglyTypedIdValueParserDelegate<TValue>
(StronglyTypedId.cs:173, StronglyTypedId.cs:161) keeping the reflection off the hot path. Each
boundary then gets one adapter: JSON through
StronglyTypedIdJsonConverterFactory and its private
StronglyTypedIdConverter<TSelf, TValue>
(MMCA.Common/Source/Core/MMCA.Common.Shared/Identifiers/StronglyTypedIdJsonConverterFactory.cs:22,
StronglyTypedIdJsonConverterFactory.cs:25, StronglyTypedIdJsonConverterFactory.cs:38), MVC route
and query binding through StronglyTypedIdTypeConverter<TSelf, TValue>
and the StronglyTypedIdTypeConverters registrar
(MMCA.Common/Source/Core/MMCA.Common.Shared/Identifiers/StronglyTypedIdTypeConverter.cs:21,
StronglyTypedIdTypeConverter.cs:82, StronglyTypedIdTypeConverter.cs:89,
StronglyTypedIdTypeConverter.cs:119), object mapping through
StronglyTypedIdMappings<TSelf, TValue>, whose four static
methods are the contract because Mapperly discovers them by signature on a closed generic type
(MMCA.Common/Source/Core/MMCA.Common.Shared/Identifiers/StronglyTypedIdMappings.cs:31,
StronglyTypedIdMappings.cs:38-53), and discovery through
StronglyTypedIdRegistry
(MMCA.Common/Source/Core/MMCA.Common.Shared/Identifiers/StronglyTypedIdRegistry.cs:22), which scans
assemblies up front and hands out the type list (StronglyTypedIdRegistry.cs:28,
StronglyTypedIdRegistry.cs:66, StronglyTypedIdRegistry.cs:73) because EF needs it before any entity
exists. One more Shared constant class rounds the group out:
MessageHeaders
(MMCA.Common/Source/Core/MMCA.Common.Shared/Messaging/MessageHeaders.cs:22) names the four
MMCA--prefixed transport headers a message carries across a boundary (tenant, user, roles, and
correlation id, MessageHeaders.cs:25-34), so a publisher and a consumer in different services spell
them identically.
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 15-minute marker lifetime (SoftDeletedUserCache.cs:32, SoftDeletedUserCache.cs:45)
so the module that deletes an account writes exactly the key the middleware reads. The marker has to
outlive every access token issued before the delete, because a host that does not run Identity
registers no validator query and the marker is the only thing that rejects a deleted user's
still-valid token there; it therefore matches the default access-token lifetime, and a host that
raises that lifetime writes its own marker with a matching duration (SoftDeletedUserCache.cs:22-31). 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:39-46). 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:92,107,136,172and six onMMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.API/Controllers/CustomersController.cs:53,65,82,114,135,150, both controllers naming the replacing guard in an inline comment next to the attribute (ShoppingCartsController.cs:87-92,CustomersController.cs:51-53). - 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:79-84), but that gate is per-controller code, not something the attribute demands.
FallbackAuthorizationOptions
MMCA.Common.API ·
MMCA.Common.API.Authorization.Fallback·MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/Fallback/FallbackAuthorizationOptions.cs:14· Level 0 · class (options)
- What it is: a host-configurable options object controlling the framework's fallback authorization policy, whether it runs at all and which request path prefixes it never gates.
- Depends on: nothing first-party.
- Concept introduced, deny-by-default as the ASP.NET Core
FallbackPolicy.[Rubric §11, Security](assesses whether an endpoint with no authorization metadata fails closed rather than publishing itself anonymously) and[Rubric §17, DevOps & Deployment](assesses whether the rule ships with a documented, code-level opt-out rather than forcing every host to fork it). An endpoint that declares neither[Authorize]nor[AllowAnonymous]used to be anonymous by omission; this options type is what a host tunes before flipping that default. - Walkthrough:
DefaultExemptPathPrefixes(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/Fallback/FallbackAuthorizationOptions.cs:21-44) is a static, read-only seed list of framework and static surfaces that are endpoint-routed but carry no authorization metadata of their own: Blazor's framework files and circuit (/_framework,/_blazor,/_content,/_vs), health probes (/health,/alive), well-known documents (/.well-known,/robots.txt,/sitemap.xml,/manifest.json,/site.webmanifest,/service-worker.js,/apple-app-site-association), and static asset roots (/css,/js,/lib,/images,/img,/fonts,/media,/favicon.ico).Enabled(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/Fallback/FallbackAuthorizationOptions.cs:52) defaults totrue; the doc comment states the opt-out is deliberate (AddAuthorizationPolicies(options => options.Enabled = false)) rather than by omission (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/Fallback/FallbackAuthorizationOptions.cs:47-49).ExemptPathPrefixes(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/Fallback/FallbackAuthorizationOptions.cs:64) is a mutableIList<string>seeded with[.. DefaultExemptPathPrefixes], so a host appends its own static roots without losing the framework defaults; the doc comment is explicit that application endpoints belong on[AllowAnonymous], not in this list, because that is what the anonymous-endpoint fitness gate reads (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/Fallback/FallbackAuthorizationOptions.cs:58-59). - Why it's built this way: matching is segment-based and case-insensitive against these prefixes
(per the
ExemptPathPrefixesdoc comment), so a coarse prefix like/_frameworkcovers everything beneath it without enumerating individual asset paths. - Caveats / not-in-source: the exemption is purely path-based. An undecorated controller routed
under one of these prefixes would pass ungated, and the doc comment names
AnonymousEndpointTestsBase.Endpoints_DeclareAnAuthorizationDecisionas the gate that keeps such a controller from landing (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/Fallback/FallbackAuthorizationOptions.cs:59-62). That test is off by default, so a repo only gets this protection once it opts into the stricter gate (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Bases/Api/AnonymousEndpointTestsBase.cs:55-56,118). - Where it's used: bound via
services.AddOptions<FallbackAuthorizationOptions>()and read byFallbackAuthorizationHandlerinsideAuthorizationExtensions.AddAuthorizationPolicies.
FallbackAuthorizationRequirement
MMCA.Common.API ·
MMCA.Common.API.Authorization.Fallback·MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/Fallback/FallbackAuthorizationRequirement.cs:16· Level 0 · class (sealed)
- What it is: the marker
IAuthorizationRequirementattached to the framework's fallback authorization policy. - Depends on:
Microsoft.AspNetCore.Authorization.IAuthorizationRequirement(framework). - Concept introduced, the requirement half of the fallback requirement/handler pair.
[Rubric §11, Security]and[Rubric §2, Design Patterns](assesses whether the ASP.NET Core requirement/handler split is used as designed even for a policy with no data of its own). UnlikePermissionRequirement, this requirement carries no permission string: the whole type is a one-line marker,public sealed class FallbackAuthorizationRequirement : IAuthorizationRequirement;(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/Fallback/FallbackAuthorizationRequirement.cs:16). - Walkthrough: no members; presence in a policy's requirement list is the entire signal
FallbackAuthorizationHandlerreacts to. - Why it's built this way: a data-free requirement is enough here because the decision (is the
caller authenticated, or is the path exempt) needs no configuration carried on the requirement
itself; the configuration instead lives on
FallbackAuthorizationOptions, injected into the handler. - Where it's used: attached to the built-in
AuthorizationPolicyset as ASP.NET Core'sAuthorizationOptions.FallbackPolicybyAuthorizationExtensions.AddAuthorizationPolicieswhenFallbackAuthorizationOptions.Enabledis true, and evaluated byFallbackAuthorizationHandler.
OwnerOrAdminFilterOptions
MMCA.Common.API ·
MMCA.Common.API.Authorization·MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnerOrAdminFilterOptions.cs:13· 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;
System.ComponentModel.DataAnnotationsfor[Required]. - 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 the bypass role is required rather than defaulted) and[Rubric §15, Best Practices & Code Quality](assesses whether a second host can reuse a component without forking it). The class doc comment (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnerOrAdminFilterOptions.cs:5-12) states the current shape: the claim and route parameter keep the framework's conventional names (customer_idandid), but the bypass role has no default because the framework knows no role names, so a host that applies the filter configures its own role throughservices.Configure<OwnerOrAdminFilterOptions>(o => o.BypassRole = MyRoleNames.Admin), and a host that never applies the filter configures nothing. - Walkthrough: three mutable auto-properties.
OwnerClaimTypestill defaults to"customer_id"(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnerOrAdminFilterOptions.cs:16).BypassRolenow carries[Required]and defaults tostring.Empty, not"Admin"(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnerOrAdminFilterOptions.cs:23-24); its doc comment states the options are validated against their data annotations on first resolve, so a host that applies the filter without naming a role fails loudly instead of silently bypassing for nobody (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnerOrAdminFilterOptions.cs:19-22).OwnerParameterNamestill defaults to"id"(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnerOrAdminFilterOptions.cs:31); its doc comment (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnerOrAdminFilterOptions.cs:26-30) 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; requiringBypassRolerather than defaulting it to"Admin"means a host cannot silently ship a filter that bypasses for a role it never granted to anyone. - 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.
FallbackAuthorizationHandler
MMCA.Common.API ·
MMCA.Common.API.Authorization.Fallback·MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/Fallback/FallbackAuthorizationHandler.cs:32· Level 1 · class (sealed)
- What it is: the
AuthorizationHandler<FallbackAuthorizationRequirement>that decides whether an endpoint carrying no authorization metadata of its own should be allowed through: it succeeds for an authenticated caller, an exempt path, a request that matched no endpoint and names no web-root file, or an API-versioning rejection endpoint, and otherwise leaves the requirement unsatisfied. - Depends on:
FallbackAuthorizationRequirement,FallbackAuthorizationOptions(the exempt path prefixes, taken asIOptions<FallbackAuthorizationOptions>on the primary constructor,MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/Fallback/FallbackAuthorizationHandler.cs:32);Microsoft.AspNetCore.Http,Microsoft.AspNetCore.Authorization. - Concept introduced, path-based exemption over endpoint routing's ambient resource.
[Rubric §11, Security](assesses whether the fail-closed default still lets framework and static surfaces through without requiring[AllowAnonymous]on infrastructure that was never meant to declare authorization metadata) and[Rubric §7, Microservices Readiness](assesses whether the handler works without extra DI plumbing; it needs noIHttpContextAccessor). - Walkthrough:
HandleRequirementAsync(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/Fallback/FallbackAuthorizationHandler.cs:39-55) null-guards both arguments and succeeds the requirement when any ofcontext.User.Identity?.IsAuthenticated == true,IsExemptPath(context.Resource),IsUnmatchedRequest(context.Resource)orIsApiVersioningRejection(context.Resource)holds (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/Fallback/FallbackAuthorizationHandler.cs:46-49); otherwise it returns without callingcontext.Succeed, leaving the requirement unmet.IsUnmatchedRequest(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/Fallback/FallbackAuthorizationHandler.cs:76-93) returnsfalsefor a non-HTTP resource or any request that matched an endpoint (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/Fallback/FallbackAuthorizationHandler.cs:78-81); for an unmatched request it consults theIWebHostEnvironment.WebRootFileProviderand passes only when neither a file nor a directory exists at the path (or no web root is available) (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/Fallback/FallbackAuthorizationHandler.cs:85-92). Nothing sits behind such a request, and challenging it would turn an unknown URL into a sign-in redirect instead of letting the host's not-found re-execute render the 404; a request for an existing web-root file keeps the gate becauseUseStaticFileswould still serve it.IsApiVersioningRejection(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/Fallback/FallbackAuthorizationHandler.cs:64-71) recognizes Asp.Versioning's client-error endpoints (no metadata, request delegate declared in theAsp.Versioning.Routingnamespace, constant atMMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/Fallback/FallbackAuthorizationHandler.cs:36), so an anonymous caller with a bad api-version gets the 400 version error rather than a 401 (rationale comment atMMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/Fallback/FallbackAuthorizationHandler.cs:57-63).IsExemptPath(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/Fallback/FallbackAuthorizationHandler.cs:95-107) pattern-matchescontext.ResourcetoHttpContextand returnsfalsefor anything else (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/Fallback/FallbackAuthorizationHandler.cs:97-100), then checks the request path againstoptions.Value.ExemptPathPrefixeswithStartsWithSegments(prefix, StringComparison.OrdinalIgnoreCase)(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/Fallback/FallbackAuthorizationHandler.cs:104-106). The comment aboveIsUnmatchedRequest(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/Fallback/FallbackAuthorizationHandler.cs:73-75) explains why noIHttpContextAccessoris needed: under endpoint routing the authorization resource IS theHttpContext, and a non-HTTP resource (a SignalR hub invocation) simply has no exempt path and falls through to the authenticated-user rule. - Why it's built this way: reading the resource directly off
AuthorizationHandlerContextinstead of injectingIHttpContextAccessorkeeps the handler stateless and singleton-safe; never callingcontext.Fail()follows the same ASP.NET Core convention asPermissionAuthorizationHandler, abstaining rather than vetoing. - Where it's used: registered as an
IAuthorizationHandlersingleton and wired as ASP.NET Core'sAuthorizationOptions.FallbackPolicyhandler byAuthorizationExtensions.AddAuthorizationPolicieswheneverFallbackAuthorizationOptions.Enabledis true.
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:13· 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:13), and the sharedClaimsPrincipalextension methodsHasPermissionClaim/GetRoleValues(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionAuthorizationHandler.cs:30-31). - 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 role lookup used byGetRoleValues()reads 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:17) null-guards both arguments (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionAuthorizationHandler.cs:21-22), then short-circuits to a completed task when the principal is not authenticated (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionAuthorizationHandler.cs:24-27), so an anonymous request never succeeds. It then succeeds the requirement if eithercontext.User.HasPermissionClaim(requirement.Permission)(a directly granted permission) orpermissionRegistry.HasPermission(context.User.GetRoleValues(), requirement.Permission)(a role-derived grant) holds (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionAuthorizationHandler.cs:30-31). The handler no longer carries its own role-gathering helper: the three-claim-type role lookup (the standardClaimTypes.RoleURI plus the raw"role"/"roles"claims) that used to live as a privateGetRolesmethod here now lives once, on the sharedClaimsPrincipalExtensions.GetRoleValues()used by both this handler andICurrentUserService.Roles, so the two call sites cannot restate the predicate and drift. - 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. Moving the role-claim-type lookup into a shared extension (rather than a private method restated per call site) is what removed the duplication the prior version flagged in a comment. - Where it's used: registered as an
IAuthorizationHandlersingleton byAuthorizationExtensions.AddAuthorizationPolicies(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AuthorizationExtensions.cs:70-71); 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:72-73). - 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:14· Level 3 · class (static, extension block)
- What it is: the DI wiring for the whole authorization model: one overload set that turns on ASP.NET Core authorization, installs the permission mechanism (handler, on-demand provider, registry) and the framework's fallback policy, and one method each module uses to declare its role-to-permission grants.
- Depends on:
PermissionAuthorizationHandler,PermissionPolicyProvider,IPermissionRegistry,IPermissionCatalog,PermissionRegistryBuilder,FallbackAuthorizationOptionsandFallbackAuthorizationHandler;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:16) is the C#extension(T)DI idiom taught in the primer: it addsAddAuthorizationPoliciesandAddPermissionsdirectly ontoIServiceCollection. The doc comment on the no-argument overload (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AuthorizationExtensions.cs:18-23) states the permission model plainly: 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. The parameterized overload's doc comment (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AuthorizationExtensions.cs:28-61) states the fallback half: an endpoint declaring NO authorization metadata now requires an authenticated caller instead of publishing anonymously by omission, exempt paths are seeded fromFallbackAuthorizationOptions.DefaultExemptPathPrefixes, the opt-out isAddAuthorizationPolicies(options => options.Enabled = false), and two surfaces need a decision before adopting it in an existing host: a YARP gateway's proxied routes need"AuthorizationPolicy": "anonymous"per public route (or the opt-out), and a routable Blazor page is gated byAuthorizeRouteView, which reads attributes and ignores this policy entirely, so a page still declares[Authorize]/[AllowAnonymous]for itself. The same comment also notes that a request matching no endpoint and naming no web-root file is not gated, so it reaches the 404 (and a host's not-found re-execute) instead of a sign-in challenge (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AuthorizationExtensions.cs:40-41). - Walkthrough
AddAuthorizationPolicies()(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AuthorizationExtensions.cs:25-26): a one-line forward toAddAuthorizationPolicies(configureFallback: null), so the fallback policy is enabled with its default exempt list unless a host opts out.AddAuthorizationPolicies(Action<FallbackAuthorizationOptions>? configureFallback)(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AuthorizationExtensions.cs:62): callsservices.AddAuthorization()(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AuthorizationExtensions.cs:64) 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:70-71),ReplaceinstallsPermissionPolicyProvideras the transientIAuthorizationPolicyProvider(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AuthorizationExtensions.cs:72-73), andEnsurePermissionRegistry(services)guarantees a registry exists (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AuthorizationExtensions.cs:74). It then bindsFallbackAuthorizationOptions, appliesconfigureFallbackwhen supplied, registersFallbackAuthorizationHandleras a singletonIAuthorizationHandler, and configuresAuthorizationOptions.FallbackPolicythrough the options pipeline (Configure<IOptions<FallbackAuthorizationOptions>>) rather than inline, so theEnabledflag is read after everyAddAuthorizationPolicies/Configurecall the host makes (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AuthorizationExtensions.cs:76-93): aFallbackAuthorizationRequirement-carrying policy whenEnabledis true,null(no fallback policy) when it is false.AddPermissions(Action<PermissionRegistryBuilder> configure)(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AuthorizationExtensions.cs:105): the per-module entry point for declaring grants. It guards the callback (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AuthorizationExtensions.cs:107), fetches the shared builder viaEnsurePermissionRegistry, and invokesconfigure(builder)so the module's grants accumulate (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AuthorizationExtensions.cs:109-110). The doc comment (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AuthorizationExtensions.cs:98-104) 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:119): 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:121-125); otherwise it creates one, registers it, and registers the builtPermissionRegistryas a singleton factory (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AuthorizationExtensions.cs:127-129). BothIPermissionRegistryandIPermissionCatalogare then registered as singleton factories that resolve that same concretePermissionRegistry(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AuthorizationExtensions.cs:135-136); the inline comment (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AuthorizationExtensions.cs:131-134) states why: both contracts must forward to the ONE built instance so what an administration screen enumerates and what an authorization check answers from can never disagree, and callingbuilder.Build()a second time to satisfy the second contract would build two. 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:116-118makes.
- Why it's built this way:
TryAddEnumerablelets the permission handler (and, separately, the fallback 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(). ResolvingIPermissionRegistryandIPermissionCatalogfrom the same singleton factory output (rather than each callingbuilder.Build()) is what keeps the read side and the enforcement side of the permission model looking at one object. - 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.Authentication.cs:77, the call atWebApplicationBuilderExtensions.Authentication.cs:116) andAddCommonAuthentication(WebApplicationBuilderExtensions.Authentication.cs:133, the call atWebApplicationBuilderExtensions.Authentication.cs:155).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:68).
OwnershipHelper
MMCA.Common.API ·
MMCA.Common.API.Authorization·MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnershipHelper.cs:11· Level 9 · class (static)
- What it is: static helpers a controller calls to enforce ownership: to scope a query to the
current user's own data (a specification that filters by owner id, or
nullwhen the caller holds the privileged bypass role), to refuse a scoped read whose owner claim cannot be resolved, and to check that the caller owns one specific record. - Depends on:
ICurrentUserService(Application layer, imported atMMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnershipHelper.cs:1), plusResultandErrorfor the two gate methods. 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)(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnershipHelper.cs:18): a case-insensitive compare of the current user'sRoleagainst the bypass role (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnershipHelper.cs:21).bypassRolehas no default argument: every caller supplies its own, normally sourced fromOwnerOrAdminFilterOptions.BypassRole, so the framework itself declares no role names (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnershipHelper.cs:14-17).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:35): the general form. It returnsnullfor a bypass-role caller (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnershipHelper.cs:46-49, 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:51-52). LikeIsAdmin,bypassRoleis a required parameter with no default (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnershipHelper.cs:39). Thewhere TId : struct, IParsable<TId>constraint (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnershipHelper.cs:41) 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:65): the convenience overload that fixesTIdtointand the claim to"customer_id", but still takesbypassRoleas a required parameter and forwards it (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnershipHelper.cs:68).RequireResolvableOwner<TId>(currentUserService, claimType, bypassRole, source, target)(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnershipHelper.cs:91): the fail-closed gate to call before a read scoped throughGetOwnershipSpecification. It returnsResult.Success()when the caller passesIsAdminor carries a parsable owner claim, and otherwise aForbiddenfailure (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnershipHelper.cs:101-103). The doc comment frames a missing claim as an authorization answer (403), never an unscoped read and never a 500 (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnershipHelper.cs:73-78).ValidateOwnershipAsync<TId>(..., isOwnedBy, source, target, cancellationToken)(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnershipHelper.cs:133): the single-record gate. A bypass-role caller passes without a lookup (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnershipHelper.cs:146-149); an unresolvable owner claim is refused with the sameForbiddenfailure and the lookup never runs (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnershipHelper.cs:151-154); otherwise the privateCheckOwnershipAsync(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnershipHelper.cs:159) awaits the caller-suppliedisOwnedBy(ownerId, cancellationToken)predicate (typically anExistsAsyncquery) and answersError.NotFoundwith the given source and target when it is false.AccessDenied(source, target)(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnershipHelper.cs:174): the one private factory both gates share,Error.Forbidden("Error.Forbidden", "Access denied.", source, target).
- 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. Every method takesbypassRoleas a required parameter and checks it through the sameIsAdmin, so the scope and both gates agree on who is privileged, and, likeOwnerOrAdminFilterOptions.BypassRole, the framework names no default role anywhere in the ownership axis.ValidateOwnershipAsyncanswers a non-owner with 404 rather than 403 so the existence of another owner's record cannot be probed for (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnershipHelper.cs:113-114). - Where it's used: MMCA.Store's
ShoppingCartsControllerwrapsGetOwnershipSpecificationandRequireResolvableOwnerin private helpers (MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.API/Controllers/ShoppingCartsController.cs:65-67,79-84), andOrdersControllerdoes the same (MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.API/Controllers/OrdersController.cs:66-68,80-85), also routing its mutating endpoints throughValidateOwnershipAsync(MMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.API/Controllers/OrdersController.cs:386-390). In MMCA.ADC,EventQuestionAnswersControllerandSessionQuestionAnswersControllerscope by user id withGetOwnershipSpecificationand gate withRequireResolvableOwner(MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Events/EventQuestionAnswersController.cs:110,138,MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/Controllers/Sessions/SessionQuestionAnswersController.cs:110,138).IsAdminalso decides the export overrideAllowUnscopedExporton all four controllers (for exampleMMCA.Store/Source/Modules/Sales/MMCA.Store.Sales.API/Controllers/OrdersController.cs:249) and is 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:40), so the helper does not itself require the returned type to be a specification: that contract is the caller's. AndGetOwnershipSpecificationalso returnsnullfor a non-admin caller whose claim is missing or unparseable (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/OwnershipHelper.cs:52), which means no scoping. It does not distinguish that case from the bypass role;RequireResolvableOwnerdoes, but only if the caller invokes it before the read, which nothing in the helper enforces.
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:85, next toIdempotencyFilteratDependencyInjection.cs:84, 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).
BrowserOrigin
MMCA.Common.API ·
MMCA.Common.API.SessionCookies·MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieSessionRefresher.cs:289· Level 0 · record struct
- What it is: a private nested value type inside
CookieSessionRefresherthat captures the browser's user-agent and remote IP from the triggering request, so the refresher's server-to-serverauth/refreshcall can pass them on. - Depends on:
HttpContextandHttpRequestMessage(ASP.NET Core / BCL) only. It isprivate readonly record struct, so nothing outside the refresher can name it. - Concept introduced, carry the real device through a server-side hop. [Rubric §11, Security]
assesses whether session records describe the true client. The type comment
(
MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieSessionRefresher.cs:283-288) explains that the origin is captured before the lock is taken, so the rotated session records the real device and IP instead of this host's own (empty user-agent, loopback address), and that the identity endpoint trustsX-Forwarded-ForthroughCommonForwardedHeaders. - Walkthrough:
From(HttpContext)(:291-297) reads theUser-Agentheader, storingnullwhen it is blank, andConnection.RemoteIpAddressas a string (possiblynull).ApplyTo(HttpRequestMessage)(:299-312) copies each non-null value onto the outgoing request:User-AgentandX-Forwarded-For, both throughTryAddWithoutValidation(:305,:310) because a real browser user-agent does not always parse as a strict product token list and the value is informational only (comment:303-304). - Why it's built this way: the doc comment says the origin is captured from the triggering request
before the lock is taken, and a
readonly record structgives value equality and no per-refresh heap allocation for two strings' worth of state. - Where it's used: created by the refresher's
GetOrRefreshAsyncpath (MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieSessionRefresher.cs:136), passed throughRefreshAsync(:152) andCallRefreshAsync(:171), and applied to theauth/refreshrequest at:190. No ADR names it and no usage exists outside the defining file.
ISessionCookieStore
MMCA.Common.API ·
MMCA.Common.API.SessionCookies·MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/ISessionCookieStore.cs:14· Level 0 · interface
- What it is: the public port for writing and clearing the two HttpOnly session cookies from server code that obtained the tokens itself, so it can store them without a browser round trip.
- Depends on:
HttpContext(ASP.NET Core) only. Its one implementation isSessionCookieStore, a thin adapter over the internalSessionCookieJarthat appliesSessionCookieSettings. - Concept introduced, a public door onto an internal cookie writer. [Rubric §11, Security]
assesses whether cookie hardening has one definition. The jar is
internal, so before this interface onlyMMCA.Common.APIitself could set the session cookies. The doc comment (MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/ISessionCookieStore.cs:7-13) names the new caller: the same-origin API proxy inMMCA.Common.UI.Web, which receives tokens from the identity endpoint and must persist them with exactly the options (HttpOnly,Secureoutside Development, the configuredSameSite, 7-day lifetime) that the session-cookie endpoints and the server-side refresher already use. Exposing two methods instead of making the jar public keeps the option-building in one place. - Walkthrough:
void Write(HttpContext context, string accessToken, string refreshToken)(:19) appends both cookies;void Clear(HttpContext context)(:23) expires both. Neither returns anything: the cookies are a side effect oncontext.Response. - Why it's built this way: the proxy keeps every credential server-side (ADR-131), so it is the one writer of the cookies on a proxied host and needs a supported way to write them that cannot drift from the endpoints' own writes.
- Where it's used: registered as a singleton by
AddServerAuthSessionCookie(MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:191); injected intoSameOriginApiProxyEndpoint(MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/SameOriginProxy/SameOriginApiProxyEndpoint.cs:33),SameOriginProxyTransformer(MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/SameOriginProxy/SameOriginProxyTransformer.cs:39) and the handoff route ofSessionHandoffEndpoints(MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/SameOriginProxy/SessionHandoffEndpoints.cs:42).
SessionClaimsToken
MMCA.Common.API ·
MMCA.Common.API.SessionCookies·MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionClaimsToken.cs:14· Level 0 · class
- What it is: a static helper that turns a signed access token into a claims-only copy: the same payload under an unsecured header with an empty signature, readable by the browser but useless as a credential.
- Depends on: BCL and NuGet only:
Base64Url(System.Buffers.Text),Encoding, andJwtSecurityTokenHandler(System.IdentityModel.Tokens.Jwt). Switched on bySessionCookieSettings.ClaimsOnlyBrowserTokens. - Concept introduced, the claims-only browser token. [Rubric §11, Security] and [Rubric §26,
Front-End Security] both assess what an XSS payload could steal. The doc comment
(
MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionClaimsToken.cs:7-13) states the trade: the client can still read the user's claims and the expiry to render its authentication state, but the token authorizes nothing, because every API validates signatures and rejects an unsigned token. Script on the page therefore holds nothing worth exfiltrating, while the UI keeps working off the same claims it always read. - Walkthrough:
UnsecuredHeader(:17) is computed once as the base64url (no padding) encoding of{"alg":"none","typ":"JWT"}, as the comment at:16records.Create(string? accessToken)(:25) returnsnullfor a blank value or anythingJwtSecurityTokenHandler.CanReadTokenrefuses (:27-30), then splits on.and, for a three-segment compact JWT, returns{UnsecuredHeader}.{payload}.(:32-33); any other shape is alsonull. The trailing dot keeps the three-segment compact form with an empty signature. - Why it's built this way: copying the payload segment verbatim (rather than re-serializing claims)
guarantees the browser sees exactly the claims and
expthe API issued (ADR-131). - Where it's used: the
/auth/session/tokenroute ofSessionCookieEndpointsunder claims-only mode (MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionCookieEndpoints.cs:75-77), the proxy's response rewrite (MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/SameOriginProxy/SameOriginProxyTransformer.cs:142) and its refresh answer (MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/SameOriginProxy/SameOriginApiProxyEndpoint.cs:369). Pinned byMMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/SessionCookies/SessionClaimsTokenTests.cs: claims and expiry kept but signature dropped (:20), the result rejected by a signature-validating API (:37), andnullfor non-JWT input (:56). - Caveats / not-in-source: "authorizes nothing" is a property of each API's JWT bearer validation
(signature required), not of this type; the test at
:37checks it against one validating handler.
SessionCookieRequest
MMCA.Common.API ·
MMCA.Common.API.SessionCookies·MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionCookieEndpoints.cs:95· 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:95). 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:35, which passes both strings straight toSessionCookieJar(:31).
SessionCookieSettings
MMCA.Common.API ·
MMCA.Common.API.SessionCookies·MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionCookieSettings.cs:11· Level 0 · class
- What it is: the options object for both session cookies: which
SameSitemode they carry, whether the browser is ever handed a real access token, and how long the cookies live. - Depends on:
SameSiteMode(ASP.NET Core) only. Read throughIOptions<SessionCookieSettings>bySessionCookieEndpoints,SessionCookieStoreandCookieSessionRefresher; its claims-only switch selectsSessionClaimsToken. - Concept introduced, one options object for every cookie write. [Rubric §11, Security] assesses
cookie hardening and credential exposure. A cookie delete only matches when it repeats the write's
attributes, so the seed, refresh, store and clear paths all read
SameSitefrom this one object instead of each choosing a value. - Walkthrough:
SameSite(:19) defaults toSameSiteMode.Lax; the doc comment (:13-18) explains the same-origin API proxy raises it toStrictbecause the cookie then authenticates data calls, not only server-side rendering.ClaimsOnlyBrowserTokens(:29) defaults tofalse; whentrue(doc:21-28),POST /auth/session/tokenanswers with a claims-only token instead of the real access token, andPOST /auth/session-cookieignores posted tokens and answers204without writing, because the server is then the only writer of the cookies.Lifetime(:38, aTimeSpan?, doc:31-37) is theMax-Ageof both cookies:null(the default) derives it fromJwt:RefreshTokenExpirationDayswhen that section is bound in the host, otherwise 7 days, so a cookie neither outlives nor undershoots the refresh token it carries. A UI host that does not bindJwtand runs a non-default refresh lifetime sets it here. The resolution itself lives inSessionCookieJar.ResolveLifetime. - Why it's built this way: the defaults are the long-standing cookie behavior (the comment at
DependencyInjection.cs:188-189says so), so a host that never opts in keepsLaxcookies and real browser tokens; the proxy tightens both settings in one place (ADR-131). - Where it's used: registered with defaults by
AddServerAuthSessionCookie(MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:190, comment at:188-189). The proxy's registration inSameOriginApiProxyServiceExtensionsconfigures it (MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/SameOriginProxy/SameOriginApiProxyServiceExtensions.cs:69-74), copyingSameSitefromSameOriginApiProxySettings.SessionCookieSameSite(defaultStrict,MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/SameOriginProxy/SameOriginApiProxySettings.cs:74) and settingClaimsOnlyBrowserTokens = true. Claims-only behavior is pinned byMMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/SessionCookies/ClaimsOnlySessionCookieEndpointsTests.cs(:27,:47,:62).
SessionRefreshStatus
MMCA.Common.API ·
MMCA.Common.API.SessionCookies·MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionRefreshOutcome.cs:4· Level 0 · enum
- What it is: the three ways a validate-or-refresh attempt can end:
Refreshed = 0(:10),Rejected = 1(:16) andUnavailable = 2(:23). - Depends on: nothing. Carried by
SessionRefreshOutcome. - Concept introduced, a dead session is not the same as an unreachable identity endpoint.
[Rubric §29, Resilience, Reliability & Business Continuity] assesses whether a transient fault is
treated as a permanent one. The doc comments draw the line:
Refreshedmeans a usable access token is available (still-valid cookie, or a rotated pair written back,:6-9);Rejectedmeans no refresh cookie or the identity endpoint refused it with 400, 401 or 403, so clearing the cookies is correct (:12-15);Unavailablecovers a 5xx, 429 or other non-refusal status, a timeout, a network failure or an unreadable body, where the cookies may still hold a live session and must be kept for a retry (:18-22). A caller that clears cookies on any failure would sign a user out over a gateway blip. - Where it's used: produced by
CookieSessionRefresher(itsClassifyFailure,CookieSessionRefresher.cs:123-126) and branched on by the proxy'sFailRefreshAsync(MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/SameOriginProxy/SameOriginApiProxyEndpoint.cs:276).
SessionTokenResponse
MMCA.Common.API ·
MMCA.Common.API.SessionCookies·MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieSessionRefresher.cs:23· 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:19-22). - 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:63).
SessionTokenResult
MMCA.Common.API ·
MMCA.Common.API.SessionCookies·MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieSessionRefresher.cs:17· 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:17), 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:63.
SessionRefreshOutcome
MMCA.Common.API ·
MMCA.Common.API.SessionCookies·MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionRefreshOutcome.cs:32· Level 9 · class
- What it is: the result of
ICookieSessionRefresher.ValidateOrRefreshAsyncand.RefreshAsync: aSessionRefreshStatus, the token when the status isRefreshed, and the upstream'sRetry-Afterhint when it isUnavailable(doc comment,MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionRefreshOutcome.cs:26-31). - Depends on:
SessionRefreshStatusandSessionTokenResult;TimeSpanfrom the BCL. - Concept: a closed result type built only through named factories, so a caller cannot assemble an
inconsistent combination (a
Rejectedoutcome carrying a token, say). It is the richer sibling of the nullableSessionTokenResult?thatGetOrRefreshAsyncstill returns: that shape cannot tell a dead session from a transient failure, this one can. [Rubric §29, Resilience, Reliability & Business Continuity] is the lens, as introduced atSessionRefreshStatus. - Walkthrough: the constructor is private (
:36-41).Status(:44),Session(:47, set only forRefreshed) andRetryAfter(:53, set only forUnavailablewhen the identity endpoint sent one) are get-only. The factories areRefreshed(SessionTokenResult session)(:58-59),Rejected()(:63), which returns one cachedRejectedOutcomeinstance (:34) since it carries no data, andUnavailable(TimeSpan? retryAfter = null)(:68-69). - Where it's used: built throughout
CookieSessionRefresherand consumed by the proxy'sFailRefreshAsync(MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/SameOriginProxy/SameOriginApiProxyEndpoint.cs:274-286):Rejectedends the session, whileUnavailablekeeps the cookies and answers503with aRetry-Aftertaken fromoutcome.RetryAfteror a short default (ADR-131).
ICookieSessionRefresher
MMCA.Common.API ·
MMCA.Common.API.SessionCookies·MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieSessionRefresher.cs:32· Level 10 · interface
- What it is: the "validate-or-refresh over the HttpOnly session cookies" port. It returns a
currently-valid access token for the request, rotating from the refresh cookie when the access
cookie has expired, in two shapes (a nullable token, or a
SessionRefreshOutcomethat separates a dead session from a transient failure), plus a forced rotation for when an API rejected a still-valid-looking access token. - Depends on:
HttpContext(ASP.NET Core),SessionTokenResult,SessionRefreshOutcomeandSessionRefreshStatus. 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:25-31) 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: three members.
Task<SessionTokenResult?> GetOrRefreshAsync(HttpContext context, CancellationToken cancellationToken = default)(:39) keeps the simple vocabulary: a value means "here is a good access token",nullmeans "no session, treat this caller as anonymous"; its doc comment (:34-38) flags that setting fresh cookies is a side effect.Task<SessionRefreshOutcome> ValidateOrRefreshAsync(...)(:49) is the same operation with the failure kept apart:Rejectedwhen there is no refresh cookie or the identity endpoint refused it,Unavailablefor any other outcome (5xx, 429, 408, a misrouted 404, a 409Auth.RefreshSuperseded, a timeout or a network failure), "so a caller that clears cookies on failure does so only for a session that is really dead" (:41-48).Task<SessionRefreshOutcome> RefreshAsync(...)(:60) exchanges the refresh cookie even when the access cookie still looks valid (the API rejected it: revoked, or signed with a rotated key), and is single-flighted exactly likeGetOrRefreshAsync, so concurrent callers holding the same refresh cookie share one rotation (:51-59). - Why it's built this way: one interface lets the SSR middleware, the
/auth/session/tokenendpoint and the same-origin proxy share a single refresh path, so exactly one type decides validity and exactly one type rotates (ADR-022, ADR-131). Callers that render anonymously on any failure keep the nullable method; callers that clear cookies use the outcome methods. It is also what makes every caller testable against a mock. - Where it's used:
GetOrRefreshAsyncbyCookieSessionRefreshMiddleware(which runs before authentication on navigations), by the/auth/session/tokenhandler (MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionCookieEndpoints.cs:68) and bySessionHandoffEndpoints(MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/SameOriginProxy/SessionHandoffEndpoints.cs:31);ValidateOrRefreshAsyncandRefreshAsyncbySameOriginApiProxyEndpoint(MMCA.Common/Source/Presentation/MMCA.Common.UI.Web/SameOriginProxy/SameOriginApiProxyEndpoint.cs:88,:321,:358). Registered as a singleton atMMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:186.
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:252(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:16· Level 11 · 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,SessionCookieSettings(viaIOptions<T>),SessionClaimsToken,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:18-19) 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:33and:80) because they are browser plumbing, not public API surface. - Walkthrough: the mapping method lives inside an
extension(IEndpointRouteBuilder endpoints)block (:21), the C# extension-member syntax this codebase uses for fluent registration (primer §2), so hosts callapp.MapSessionCookieEndpoints(). InsideMapSessionCookieEndpoints(:23-85): a route group for/auth/session-cookieis created, excluded from description, and explicitly markedAllowAnonymous()(:32-34); the comment above it (:27-31) spells out why the anonymity has to be declared rather than left implicit: thePOSTseeds the cookie jar at login (except on a claims-only host, where it writes nothing and answers204) and theDELETEclears it at logout, so both run before or after a session exists, and the default fallback authorization policy (SEC-Common-16) requires an authenticated caller on any endpoint that does not declare otherwise, which would make sign-in impossible on a Blazor host. ThePOST(:36-47) binds aSessionCookieRequestplusIOptions<SessionCookieSettings>and, unlessClaimsOnlyBrowserTokensis set (:41), callsSessionCookieJar.Appendwith the configuredSameSite(:43); either way it returns204(:46). The comment at:38-40explains the claims-only branch: on a proxied host the server is the only writer of the cookies and the browser holds nothing worth posting, and answering204rather than an error keeps a client written for the default mode signing in. TheDELETE(:49-53) callsSessionCookieJar.Deletewith the same configuredSameSite(:51) and returns204; bothDisableAntiforgery()because there is no antiforgery token cookie to validate on these calls. The/auth/session/tokenPOST(:60-82) first rejects an obvious cross-site request with403(:63-66), then awaitsrefresher.GetOrRefreshAsync(:68); anullresult becomes a401JSON body{ error = "no_session" }(:71). Otherwise the browser token is the real access token, or, on a claims-only host,SessionClaimsToken.Create(...) ?? string.Empty(:75-77, comment at:74), and aSessionTokenResponseis serialized (:78). That route isAllowAnonymous()(:81) because it authenticates via the cookies themselves. The privateIsCrossSite(:91-93) inspects theSec-Fetch-Siterequest header and treats a missing header as allowed, which the comment at:88-90attributes to older browsers. - Why it's built this way: CSRF is defended in depth rather than by antiforgery tokens. The comments
at
:58-59and:88-90spell it out:POST-only, the cookies' configuredSameSite(which already blocks cross-site cookie attachment on a POST; the comments nameLaxas the default andStricton the same-origin proxy, both set throughSessionCookieSettings), 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]. The group-levelAllowAnonymous()is the same defense-in-depth posture applied to authentication instead of CSRF: it is a deliberate, commented opt-out of the fallback policy (SEC-Common-16) rather than an oversight. The claims-only branches are what let the same three routes serve a proxied host without ever handing the browser a credential (ADR-131). - Where it's used: mapped by both Blazor Server hosts,
MMCA.ADC/Source/Hosts/UI/MMCA.ADC.UI.Web/Program.cs:283andMMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:261. 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). The claims-only branches are covered byMMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/SessionCookies/ClaimsOnlySessionCookieEndpointsTests.cs: an unsigned claims copy instead of the credential (:27), script-supplied tokens ignored (:47), and the delete using the configuredSameSite(:62).
SessionCookieJar
MMCA.Common.API ·
MMCA.Common.API.SessionCookies·MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/SessionCookieJar.cs:14· Level 11 · 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 public cookie store all emit identical cookie options.
- Depends on:
CookieOptions,HttpContext,IWebHostEnvironmentandSameSiteMode(ASP.NET Core) plus the cookie-name constants onSessionCookieEndpoints. TheSameSitevalue its callers pass comes fromSessionCookieSettings. - 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:61-68) means every write isHttpOnly = true(:63),Secureoutside Development (:64,!environment.IsDevelopment(), sohttp://localhostdev still works while every deployed environment forces HTTPS),SameSiteset to the caller's mode (:65), andPath = "/"(:66). Drift between the seed, refresh, store and clear paths is structurally impossible because all of them call this method. The conditionalSecureis the one thing an analyzer objects to, and the suppression carries its justification inline (:60and:69bracket a scoped#pragma warning disable S2092), which is the house style for an accepted deviation. - Walkthrough:
DefaultLifetime = TimeSpan.FromDays(7)(:18) is the refresh-token lifetime's default (JwtSettings.RefreshTokenExpirationDays,MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/JwtSettings.cs:64), used when the host configures neither the cookie lifetime nor theJwtsection (comment:16-17).Appendhas two overloads: the four-argument form (:20-21) forwards withSameSiteMode.Lax, and the five-argument form (:23-28) builds options once with the givenSameSiteand the resolved lifetime, and writes both cookies with it asMaxAge.ResolveLifetime(HttpContext)(:35-48, doc:30-34) picks that lifetime in order:SessionCookieSettings.Lifetimewhen the host set it (:40-43), elseJwtSettings.RefreshTokenExpirationDayswhen theJwtsection is bound and positive (:45-47), elseDefaultLifetime. It reads both throughcontext.RequestServiceswithGetService, null-tolerant because the property is null on a bareDefaultHttpContext(comment:37-38).DeletemirrorsAppend: the three-argument form (:50-51) forwards withLax, and the four-argument form (:53-58) rebuilds the options withTimeSpan.Zero, which:67turns 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. SharingBuildOptionsbetweenAppendandDelete, and having every caller pass the same configuredSameSite, guarantees that (ADR-022). TheLax-defaulting overloads keep the long-standing call shape for callers with no settings in hand. - Where it's used:
SessionCookieEndpoints(seed atSessionCookieEndpoints.cs:43, clear at:51),CookieSessionRefresher(rewrite after rotation,CookieSessionRefresher.cs:143) andSessionCookieStore(ISessionCookieStore.cs:33and:36), all passingSessionCookieSettings.SameSite. ItsBuildOptionsis also named as the precedent for the culture cookie's conditionalSecuresuppression (MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationExtensions.cs:124). 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:252,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:32). Registered scoped byAddServerAuthSessionCookie(MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:180), and covered byMMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/SessionCookies/CookieTokenReaderTests.cs.
SessionCookieStore
MMCA.Common.API ·
MMCA.Common.API.SessionCookies·MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/ISessionCookieStore.cs:28· Level 12 · class
- What it is: the internal implementation of
ISessionCookieStore: a two-line adapter that forwards toSessionCookieJarwith the configuredSameSite. - Depends on:
IWebHostEnvironmentandIOptions<SessionCookieSettings>(primary constructor,MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/ISessionCookieStore.cs:28-30),SessionCookieSettingsandSessionCookieJar. - Concept: the adapter half of the public-port-over-internal-helper split introduced at
ISessionCookieStore. It adds no behavior of its own, which is the point: every cookie option still comes from the jar's singleBuildOptions. - Walkthrough:
Write(:32-33) callsSessionCookieJar.Append(context, accessToken, refreshToken, environment, settings.Value.SameSite);Clear(:35-36) callsSessionCookieJar.Delete(context, environment, settings.Value.SameSite). Readingsettings.Valueon each call means writes and deletes always agree onSameSite. - Why it's built this way:
internal sealed, so consumers outsideMMCA.Common.APIdepend on the interface and the jar stays hidden; the one-line summary at:27names it "theISessionCookieStoreoverSessionCookieJar". - Where it's used: registered as the singleton
ISessionCookieStorebyAddServerAuthSessionCookie(MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:191); seeISessionCookieStorefor the proxy types that consume it.
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:107-108andMMCA.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).
CookieSessionRefresher
MMCA.Common.API ·
MMCA.Common.API.SessionCookies·MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieSessionRefresher.cs:75· Level 13 · class
- What it is: the singleton implementation of
ICookieSessionRefresher. It validates the access cookie's JWT locally and, when that fails (or when a caller forces it), exchanges the refresh cookie at the API'sauth/refreshendpoint server-to-server, writes the rotated pair back as cookies, single-flights concurrent refreshes so a burst of requests rotates the token only once, forwards the browser's own user-agent and IP to the identity endpoint, and classifies every failure as a dead session or a transient one. - Depends on:
IHttpClientFactory,IMemoryCache,IWebHostEnvironment,ILogger<CookieSessionRefresher>,TimeProviderandIOptions<SessionCookieSettings>(primary constructor,MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieSessionRefresher.cs:75-81);KeyedSemaphoreStripe(:88);SessionCookieJar;SessionCookieSettingsfor the cookieSameSite;SessionRefreshOutcomeandSessionRefreshStatus;CookieTokenReaderfor the Items key;SessionCookieEndpointsfor the cookie names; and theAuthenticationResponse/RefreshTokenRequestcontracts fromMMCA.Common.Shared.Auth. It reads token expiry withSystem.IdentityModel.Tokens.Jwtand namesPolly.ExecutionRejectedExceptionin its catch filter. - 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 (
:63-74) 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(:86) caches the rotated pair keyed by the OLD refresh token (:208), so a slightly-late sibling carrying the same expired pair gets the same result instead of rotating again. - Walkthrough:
GetOrRefreshAsync(:90-91) is a projection: it awaitsValidateOrRefreshAsyncand returns its.Session, so every failure becomesnull.ValidateOrRefreshAsync(:93-104) reads the access cookie (:97) and, ifTryReadValidExpirypasses, returns aRefreshedoutcome around it untouched (:98-101); otherwise it delegates toRefreshFromCookiesAsync(:103). The publicRefreshAsync(:106-112) skips the validity check and goes straight toRefreshFromCookiesAsync(:111), which is the forced rotation the interface promises.RefreshFromCookiesAsync(:128-149) returnsRejectedwhen there is no refresh cookie (:131-134), captures the browser's identity withBrowserOrigin.From(context)(:136), calls the privateRefreshAsync(:137) and passes its failure through (:138-141), writes the rotated pair withSessionCookieJar.Appendand the configuredSameSite(:143), stashes the fresh access token oncontext.Items[CookieTokenReader.FreshAccessTokenItemKey](:147, with the reason at:145-146), and returns aRefreshedoutcome around the newSessionTokenResult(:148). The privateRefreshAsync(:151-168) is textbook double-checked locking: a cache hit returns immediately (:154-157), otherwise it acquires the stripe for this token (:159) and re-checks the cache before doing any work (:162-165).CallRefreshAsync(:170-220) creates the named client (:173), builds anHttpRequestMessagePOST to the relativeauth/refreshURI carrying aRefreshTokenRequestas JSON content (:186-190), lets the captured origin stamp its headers (:191), and sends it withCancellationToken.None(:192) 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:184-185). A non-success status goes throughClassifyFailure(:194-199):Rejected, orUnavailablecarrying the parsedRetry-After. An empty access token isUnavailable(:202-205). Success caches theAuthenticationResponseunder the old refresh token forRotationGrace(:208).ClassifyFailure(:123-126) isinternal static: 400, 401 and 403 are the identity endpoint refusing the token, soRejected; anything else (5xx, 429, 408, a 404 from a misrouted gateway, and a 409) says nothing about the token, soUnavailable(doc:114-122). The 409 case isAuth.RefreshSuperseded: another request (a second tab, or the same browser served by another replica) rotated this token a moment ago, insideRefreshSessions:ReuseGraceSeconds, and the browser picks up the winner's cookie on its next request, so the session is kept (ADR-097).ReadRetryAfter(:222-241) accepts either form of the header, a delta (:229-232) or a date measured against the injectedTimeProvider(:234-238), and clamps a negative result to zero.TryReadValidExpiry(:243-272) rejects a blank token, refuses anythingJwtSecurityTokenHandlercannot read (:252-255), treats the token as expired whenjwt.ValidTo <= timeProvider.GetUtcNow().UtcDateTime + ClockSkew(:260, withClockSkewa 30-second margin at:85), and swallows onlyArgumentException/FormatException(:268-271); it is an instance method because it reads the injected clock.CacheKey(:278) builds themmca:session-refresh:{refreshToken}string that is both the cache key and the striping key; the comment at:274-277explains 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]: the injectedTimeProvideris the same kind of affordance, letting tests move the clock instead of minting tokens around the wall clock. - Concept, forwarding the browser's identity on a server-to-server hop. [Rubric §11, Security]
and [Rubric §13, Observability & Operability] apply to what the identity endpoint records. Because
this host, not the browser, calls
auth/refresh, a bare call would stamp the rotated session with the host's own empty user-agent and loopback address. The privateBrowserOriginrecord struct (:283-313) is captured from the triggering request before the lock is taken (:136, doc:283-288):Fromreads theUser-Agentheader, treating blank asnull, and the connection's remote IP (:291-297).ApplyTo(:299-312) addsUser-AgentandX-Forwarded-Forto the outbound request withTryAddWithoutValidation(:305,:310), because a real browser user-agent does not always parse as a strict product-token list and the value is informational only. The identity endpoint trustsX-Forwarded-ForthroughCommonForwardedHeaders. Capturing it beforeRefreshAsyncmatters because the work that follows runs under the lock and cache, where the triggeringHttpContextis no longer the thing being read. - Concept, an SSR-safe failure mode that does not sign users out. [Rubric §29, Resilience,
Reliability & Business Continuity] and [Rubric §13, Observability & Operability] apply to the
outbound call.
CallRefreshAsyncwraps the POST in atrywhose filter narrows toHttpRequestException,OperationCanceledException,JsonException,NotSupportedExceptionandPolly.ExecutionRejectedException(:214-215); the comment at:211-213explains the last one: the resilience pipeline's own refusals (a timed-out attempt, an open circuit, a rate-limited call) throw it, for exampleTimeoutRejectedExceptionwith the gateway down, which is neither transport exception. The catch logs one warning through the source-generatedLogRefreshCallFailed(:217, declared with[LoggerMessage]at:280-281, which is why the class ispartialat:75) and returnsUnavailable(:218). The comment at:175-181gives 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; it isUnavailable, notRejected, because nothing said the refresh token is dead, so a caller that clears cookies must keep them. The failure is deliberately not cached (only a successful rotation reachescache.Setat:208), 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]. Separating
RejectedfromUnavailableis what lets the same-origin proxy clear cookies only for a session that is really over and answer a transient identity-endpoint fault with503plusRetry-After(ADR-131). - Where it's used: resolved as
ICookieSessionRefresherbyCookieSessionRefreshMiddleware, by the/auth/session/tokenendpoint, and by the same-origin proxy (SameOriginApiProxyEndpoint,SessionHandoffEndpoints). Its namedHttpClient,RefreshClientName = "SessionCookieRefreshClient"(:83), is configured with the API base address inAddServerAuthSessionCookie(MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs:182-183), which also registers the refresher as a singleton (:186) with an inline note (:185) that a shared instance across requests is what makes single-flight work at all, and registersTimeProvider.SystemwithTryAdd(:196) because a Blazor Web host calling only this method has no other clock registration (comment:193-195). The validate, rotate, grace-cache and failure paths are covered byMMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/SessionCookies/CookieSessionRefresherTests.cs, including refusal asRejected(:362,:377), undecidable statuses asUnavailablewith no cookies written (:389), the rotation-race 409 asUnavailablethat keeps the cookies (:419,:446), resilience-pipeline rejections (:465), bothRetry-Afterforms (:489,:505), the forwarded user-agent and IP (:80,:97) and expiry judged against the injected clock (:61).
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:108andMMCA.Store/Source/Hosts/UI/MMCA.Store.UI.Web/Program.cs:112, chained onto the host'sAddAuthentication(SessionCookieAuthenticationHandler.SchemeName)call on the preceding line.
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:160-163), 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 the escaped address and token as a URL fragment,#email=...&token=...(MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ForgotPassword/ForgotPasswordHandlerBase.cs:153-155). A fragment is never sent to the server in the HTTP request, which is the reason the doc comment gives (:16-17): the live token never reaches a server or proxy access log the way a query string would. The property carries a scopedCA1056suppression (:21-24) explaining why it is astringand not aSystem.Uri: it is bound fromPasswordReset__ResetUrl, concatenated with a URL fragment, 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:160-163, immediately before the token service that reads it is registered at:164); consumed by PasswordResetTokenService as a snapshot field (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:45) 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 (:126) and renders the fragment link (:153-155).
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 AuthSessionIssuer, 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: seven 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:336-339), 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:182-186, 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).ReuseGraceSeconds(:98,[Range(0, 300)], default 10) is how long after a session was rotated a second presentation of its token counts as a rotation race rather than reuse (:82-96). Two tabs, or one browser served by two replicas, can present the same refresh token a moment apart; the later request is answered409 Conflict(Auth.RefreshSuperseded) with nothing revoked, and the client retries with the winner's token. The default matches the cookie refresher's rotation grace. The comment is explicit that this is a security trade-off: a stolen-token replay arriving inside the window gets a 409 instead of the BR-206 family revocation. Only a row revoked asRotatedqualifies, so a row already flagged as reuse still revokes the family however recent, and0treats every rotated token that comes back as reuse.
- 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:168-171) 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:168-171; injected asIOptions<RefreshSessionSettings>into AuthSessionIssuer (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Sessions/AuthSessionIssuer.cs:44, read for the reuse grace at:481and the cap at:514), into EFRefreshSessionStore (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Auth/EFRefreshSessionStore.cs:35), and into RefreshSessionCleanupService (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Auth/RefreshSessionCleanupService.cs:51, snapshotted at:55). 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:173-174). The app Identity services no longer take it: the issuer owns it, so ADC'sAuthenticationServicehas no reference to it.
UserAdministrationQuery
MMCA.Common.Application ·
MMCA.Common.Application.Auth.Administration·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Administration/IUserAdministrationService.cs:79· Level 0 · record struct
- What it is: the paging and filter parameters for IUserAdministrationService<TUserDto>'s
ListAsync: a page number, a page size, an optional free-text search term, and an optional role filter (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Administration/IUserAdministrationService.cs:79-83). - Depends on: nothing beyond the BCL (
string,int). It exists purely as the input shape to IUserAdministrationService<TUserDto>.ListAsync. - Concept introduced: a query parameter object at an admin list boundary.
[Rubric §9, API & Contract Design]assesses whether a list endpoint's inputs are a named, typed shape rather than several loose parameters strung along a signature. Areadonly record structgives value equality for free and keeps the four knobs, paging plus the two optional filters, together at the controller, the service, and any test double. - Walkthrough: a four-member positional declaration:
PageNumberandPageSize(required),SearchTermandRole(bothstring?, defaultnull, meaning "no filter"). - Why it's built this way: a
record structrather than a class avoids a heap allocation for a value that is built once, passed to one call, and discarded, and the two optional filters default tonullso a caller that wants an unfiltered page supplies only the paging pair. - Where it's used: built by
UsersAdminControllerBase(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Administration/UsersAdminControllerBase.cs) from the incoming query string and passed to IUserAdministrationService<TUserDto>.ListAsync; consumed by each app'sUserAdministrationService, for exampleMMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/Administration/UserAdministrationService.cs.
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 (and, since it also implements IPermissionCatalog, the fallback catalog) 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 and IPermissionCatalog (
:20), 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, and now that it doubles as the fallback IPermissionCatalog it renders an empty catalog rather than throwing, so a role-administration screen on an unconfigured host reads as "this host grants nothing yet" instead of failing (:225-228).[Rubric §13, Observability & Operability]assesses whether an operator can tell why: the log message spells out the remedy verbatim (:264-267, 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, two catalog properties, and three methods.
private static readonly HashSet<string> None = [](:221) is the single empty grant set every permission call returns, andprivate int _warned(:223) is the one-time-warning latch.IReadOnlyList<string> Roles => [](:229) andIReadOnlyList<string> Permissions => [](:232) are the IPermissionCatalog members: both always empty, so an unconfigured host's role editor renders no roles and no permissions to pick from rather than erroring.GetPermissions(string role)(:235-239) warns then returnsNone.HasPermission(IEnumerable<string> roles, string permission)(:242-249) argument-guards both parameters (:244-245) before warning and returningfalse, so a caller bug still surfaces as anArgumentExceptionrather than being swallowed by the stub.WarnOnce()(:256-262) usesInterlocked.Exchange(ref _warned, 1) == 0(:258) so concurrent requests produce exactly one log line. The comment (:251-255) explains why the warning is deferred to the first permission 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. Reading the catalog properties never triggers the warning, onlyGetPermissions/HasPermissiondo.LogNoPermissionsConfigured(:264-267) 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:123), 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. Adding IPermissionCatalog to the same fallback type, rather than a second stub class, means the oneTryAddSingletoncall keeps both ports covered from the same no-op instance. - Where it's used: reached through the IPermissionRegistry dependency of the authorization command and query decorators in the CQRS pipeline (see group 05), and through the IPermissionCatalog dependency of IRoleAdministrationService's
GetCatalogAsyncon a host that never configured a registry. - 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:67), 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:154-157) 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:39), 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:44, 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:164.
IRoleAdministrationService
MMCA.Common.Application ·
MMCA.Common.Application.Auth.Administration·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Administration/IRoleAdministrationService.cs:29· Level 3 · interface
- What it is: the application-layer contract for a role-permission administration screen: list every role with its compiled and stored permissions, read one role, read the closed catalog of roles and permissions an editor may pick from, and replace one role's stored permissions (
MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Administration/IRoleAdministrationService.cs:29-77). - Depends on:
RolePermissionsResponseandPermissionCatalogResponse(both inMMCA.Common.Application.Auth.Administration), and Result and its generic form. - Concept introduced: an editor's writable set is a strict subset of its readable catalog.
[Rubric §11, Security]assesses whether an administrative surface can grant more than it should. The remarks onGetCatalogAsync(:311-315) state that the permission list it returns is the compiled catalog, so an editor built from that response can never submit anythingSetStoredPermissionsAsyncwould refuse as unknown, and the parameter doc onSetStoredPermissionsAsync(:324-328) adds the one hard exclusion: no submitted list may includeAdministrationPermissions.ManageRoles, so a role editor can never grant a role the permission to edit roles. - Walkthrough: four methods.
ListRolesAsync(CancellationToken cancellationToken = default)(:297) returns oneRolePermissionsResponseper known role, ordered by role name.GetRoleAsync(string role, CancellationToken cancellationToken = default)(:305) reads one role's compiled and stored permissions, matched case-insensitively, or a not-found failure.GetCatalogAsync(CancellationToken cancellationToken = default)(:318) returns the two closed sets, roles and permissions, an editor renders from, both sorted ordinally.SetStoredPermissionsAsync(string role, IReadOnlyList<string> permissions, string? changedBy = null, CancellationToken cancellationToken = default)(:332-336) replaces a role's stored permissions and invalidates the cached snapshot, recording an optionalchangedByprincipal name on the rows it creates.
- Why it's built this way: separating the compiled catalog (
GetCatalogAsync) from the writable operation (SetStoredPermissionsAsync), and stating theManageRolesexclusion as a contract obligation rather than leaving it to each implementation, keeps every host's role editor equally safe against granting itself the permission that edits roles. - Where it's used: implemented by
StoredPermissionRoleAdministrationService(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/Administration/StoredPermissionRoleAdministrationService.cs); consumed byRolesAdminControllerBase(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Administration/RolesAdminControllerBase.cs) and, through it, by each app's admin roles controller, for exampleMMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/AdminRolesController.cs; registered throughMMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs.
IUserAdministrationService<TUserDto>
MMCA.Common.Application ·
MMCA.Common.Application.Auth.Administration·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Administration/IUserAdministrationService.cs:24· Level 3 · interface
- What it is: the application-layer contract for an account administration screen: list accounts paged and filtered, read one account, lock or unlock an account, and replace an account's roles (
MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Administration/IUserAdministrationService.cs:24-70). - Depends on: UserAdministrationQuery (the list filter),
PagedCollectionResult<TUserDto>, theUserIdentifierTypealias, and Result and its generic form. Generic overTUserDto, the app's shape for one row of the admin account list. - Concept introduced: locking an account is a workflow, not a flag flip.
[Rubric §11, Security]assesses whether an administrative action actually removes access rather than merely marking it removed. The remarks onSetLockedAsync(:380-384) state the obligation directly: an implementation that locks an account should also revoke its live refresh sessions, or the lock only takes effect once the current access token expires, and name IRefreshSessionStore and RefreshSessionRevocation'sRevokeAllAsyncas what the framework's own credential-rotation paths use for exactly that. - Walkthrough: four methods.
ListAsync(UserAdministrationQuery query, CancellationToken cancellationToken = default)(:365-367) returns one page ofTUserDto, with pagination metadata.GetAsync(UserIdentifierType userId, CancellationToken cancellationToken = default)(:375) reads one account, or a not-found failure.SetLockedAsync(UserIdentifierType userId, bool locked, CancellationToken cancellationToken = default)(:390) locks or unlocks an account.SetRolesAsync(UserIdentifierType userId, IReadOnlyList<string> roles, CancellationToken cancellationToken = default)(:399-402) replaces the complete set of roles an account holds.
- Why it's built this way: generic over
TUserDtoso each app's admin list can carry app-specific columns without the framework knowing about them, while the lock/unlock contract's remarks pin the one security-relevant behavior (session revocation on lock) as a documented obligation rather than leaving it to whichever app implements the interface first. - Where it's used: consumed by
UsersAdminControllerBase(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Administration/UsersAdminControllerBase.cs); implemented by each app'sUserAdministrationService, for exampleMMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/Administration/UserAdministrationService.cs, and registered throughMMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/DependencyInjection.cs.
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.FromMinutes(15)(:32). The remarks (:22-31) justify the number rather than leaving it magic: the marker must outlive every access token issued before the delete. A host that does not run Identity registers no validator query, so the marker is the only thing rejecting a deleted user's still-valid token there; if it expired first, that token would be served again until it lapsed. It therefore lasts as long as the default 15-minute access-token lifetime (the BR-205 default on ITokenService). It is a constant rather than a read of the JWT settings because the marker is written through this static helper, where those settings are not reachable, so a host that raises the access-token lifetime should write its own marker with a matching duration. Only positive markers are written (:30).KeyFor(UserIdentifierType userId)(:45-46) buildsuser:deleted:{userId}throughstring.Create(CultureInfo.InvariantCulture, ...). The remarks (:39-44) 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)(:56-64) null-guards the cache (:61) and writestrueunderKeyFor(userId)forMarkerDuration(:63). 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:82), reads it from the shared store rather than the replica's local copy (:88-93), short-circuits with 401 when the marker istrueon every host, Identity-hosting or not (:105-112), and on a miss falls back to the validator query when one is registered (:114-122,:139-141). Only a deleted answer is cached, forMarkerDuration(:152-161): a live answer is never cached (:133-135), because every replica keeps its own local copy and a cached live answer would outlive a delete made on another. 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:146-148) and swallows a cache fault (:150-153) 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-103) and proceeding if that is also unavailable (:143-149), 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. Both ADC's and Store'sDeleteUserHandlerderive fromDeleteUserHandlerBase<User, DeleteUserCommand>(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/DeleteUser/DeleteUserHandler.cs:35,MMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/Users/UseCases/DeleteUser/DeleteUserHandler.cs:29), so both write the marker through the base.
IRefreshSessionStore
MMCA.Common.Application ·
MMCA.Common.Application.Auth·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/IRefreshSessionStore.cs:22· Level 5 · 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 double is InMemoryRefreshSessionStore (MMCA.Common/Source/Hosting/MMCA.Common.Testing/Support/InMemoryRefreshSessionStore.cs:27).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 from every lookup exceptFindByIdUntrackedAsync, which is untracked by design and for reading only, because a no-tracking read anywhere else would accept revocations and rotations and drop them silently at save time (:16-20). 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: seven members.
AddAsync(RefreshSession session, ...)(:27) stages an insert.FindByTokenHashAsync(string tokenHash, ...)(:38) finds by digest including revoked and expired rows, and the comment (:29-34) 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, ...)(:46-48) 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 (:40-43).FindByIdAsync(Guid id, UserIdentifierType userId, ...)(:60-63) takes the owner as part of the query rather than as a check the caller performs afterwards. The comment (:50-54) 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.FindByIdUntrackedAsync(Guid id, ...)(:80-81) re-reads a session as the database holds it now, bypassing any tracked copy, revoked and expired rows included (:65-79). The rotation loser needs it: its tracked instance was read before the winner's conditional update and still shows the row live, so only a fresh read can say when and why the row was revoked. The instance is for reading only, so a mutation on it is never persisted. The default returnsnull, which the caller treats as "cannot tell" and answers with the conservative BR-206 family revocation; the shipped EF store overrides it with a no-tracking query (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Auth/EFRefreshSessionStore.cs:93-95).SaveChangesAsync(...)(:86) persists staged inserts and revocations.TryRotateAsync(RefreshSession presented, RefreshSession successor, DateTime revokedAt, ...)(:116) is the one exception to the no-update rule, and it ships a default interface implementation. It argument-guards both sessions (:122-123), revokes the presented session asRefreshSession.ReasonRotatedlinked to the successor's hash (:125), then stages and saves the successor (:130-131). Theboolreturn is the whole point (:93-99): 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. The caller then re-reads the row throughFindByIdUntrackedAsyncto tell a sibling rotation inside the reuse grace (a 409 with nothing revoked) or a sign-out or cap eviction (that request fails alone) from a genuine replay (the family is revoked). 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:121and:139).
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:172, scoped, like the unit of work it shares aDbContextwith, so a login and its session insert commit together. Consumed by AuthSessionIssuer, not byAuthenticationServiceBase(injected atMMCA.Common/Source/Core/MMCA.Common.Application/Auth/Sessions/AuthSessionIssuer.cs:43) for the owner-scoped lookup behind targeted revocation (:236), the unrevoked-session reads behind family revocation and listing (:199,:496), cap eviction (:515) and the insert of a new session (:375).
AuthenticationValidators
MMCA.Common.Application ·
MMCA.Common.Application.Auth·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationValidators.cs:24· Level 5 · class (sealed)
- What it is: a tiny parameter object that bundles the three FluentValidation validators the authentication workflow needs (login, registration, refresh), plus the optional Terms of Service version registration requires, 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,:4), and, for the terms check,IOptions<LegalAcceptanceOptions>andLegalAcceptancePolicyfromMMCA.Common.Application.Auth.Legal(:2-3). - 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 ten parameters (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/AuthenticationService.cs:51-57). - Walkthrough: a primary constructor takes the three
IValidator<T>instances plus an optionalIOptions<LegalAcceptanceOptions>? legalAcceptance = null(:24-28), and three get-only properties surface the validators by name:Login(:31),Register(:34), andRefresh(:37), each assigned from its matching constructor parameter. The fourth member,CurrentTermsVersion(:43), returnsLegalAcceptancePolicy.ResolveCurrentVersion(legalAcceptance?.Value): the Terms of Service version registration requires, ornullwhen acceptance is not configured. The doc comment (:18-22) gives the motive: the terms check is a registration rule, so carrying the options here keeps the opt-in from adding a constructor dependency toAuthenticationServiceBaseor to the app's subclass. Beyond that one resolver call the type has no logic. - 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:68), whoseLoginAsync,RegisterAsync, andRefreshTokenAsynccallvalidators.Login(:129),validators.Register(:227), andvalidators.Refresh(:315) respectively before doing any work, and which re-exposesCurrentTermsVersionas a protected property (:120) thatRegisterAsynctests (:236). It is registered by each app's Identity module rather than by the framework, since one of its dependencies is app-owned:MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/DependencyInjection.cs:37andMMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/DependencyInjection.cs:46, bothTryAddScoped<AuthenticationValidators>(). The options themselves are bound by the opt-inAddLegalAcceptance(configuration)(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Auth.cs:104-110).
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(:131-139) ships a default implementation in the interface itself that returns a not-supported Error ("Auth.ExternalLoginNotSupported",:139). 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>(:23-27).RegisterAsync(RegisterRequest, string? ipAddress = null, string? userAgent = null, ...)(:37-41); theipAddressdoes double duty, feeding ILoginProtectionService's registration rate limit and the new session row (:32).RefreshTokenAsync(RefreshTokenRequest, ...)(:52-56) exchanges an expired access token plus a valid refresh token for a rotated pair.RevokeTokenAsync(UserIdentifierType userId, string? refreshToken = null, ...)(:67-70) 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, ...)(:79-81) signs every device out: a password change, an admin lockout, or an explicit "sign out everywhere".GetSessionsAsync(UserIdentifierType userId, Guid? currentSessionId = null, ...)(:96-99) 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, ...)(:115-118) revokes one named device. The remarks (:104-110) 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 changes nothing and also returnsNotFoundwith the codeAuth.SessionAlreadyRevoked(:107-109), so a device list can tell the user the device was already signed out instead of claiming this request signed it out.ExternalLoginAsync(loginProvider, providerKey, email, firstName, lastName, ...)(:131-139), 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.
RefreshSessionRevocation
MMCA.Common.Application ·
MMCA.Common.Application.Auth·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/RefreshSessionRevocation.cs:17· Level 5 · class (internal static)
- What it is: a single shared helper,
RevokeAllAsync, that revokes every un-revoked refresh session a user holds. Used wherever a credential-rotation path needs to make sure a stale token cannot keep authenticating after the event that should have invalidated it. - Depends on: IRefreshSessionStore (nullable: a host that never registered one, because refresh sessions are opt-in, is a no-op), RefreshSession (
.Revoke,RefreshSession.ReasonSignedOut), and the BCLTimeProvider. - Concept introduced: a credential-rotation event is a call site, not a feature flag.
[Rubric §11, Security]assesses whether changing a credential actually invalidates the sessions issued under the old one. Because refresh sessions are opt-in (RefreshSessionSettings.Enabled), a change-password or reset-password handler cannot assume a store exists; the doc comment states this directly (:426-428), andrefreshSessions is null(:439-442) is checked first so the call degrades to a no-op rather than throwing, meaning the same handler code runs unchanged on a host that never enabled the feature. - Walkthrough: one static method,
RevokeAllAsync(IRefreshSessionStore? refreshSessions, TimeProvider? timeProvider, UserIdentifierType userId, CancellationToken cancellationToken)(:433-437). It returns immediately on a null store (:439-442) or an empty session list (:446-449), otherwise stampsnowfrom the suppliedtimeProviderorTimeProvider.System(:444), callssession.Revoke(now, RefreshSession.ReasonSignedOut)on every un-revoked session in a loop (:451-454), and saves once (:456). - Why it's built this way:
internal staticwith no instance state keeps the helper a one-line call from any handler that needs it, and centralizing the null-store check here means every caller gets the same opt-in-safe behavior instead of re-deriving it. - Where it's used: the documented mechanism behind IUserAdministrationService<TUserDto>'s
SetLockedAsyncobligation (see that type); alsoChangePasswordHandlerBase(MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ChangePassword/ChangePasswordHandlerBase.cs) andResetPasswordHandlerBase(MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ResetPassword/ResetPasswordHandlerBase.cs).
AuthenticationServiceBase<TUser>
MMCA.Common.Application ·
MMCA.Common.Application.Auth·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:64· 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), IPasswordHasher, ILoginProtectionService, AuthenticationValidators and IAuthSessionIssuer (the five required constructor parameters,
:64-68), and two optional ones: an ITwoFactorAuthenticator?andIOptions<EmailConfirmationSettings>?(:69-70, both defaultnullso a subclass that adopts neither passes nothing), the IAuthUser credential contract plus AuditableAggregateRootEntity<TIdentifierType> as theTUserconstraint (:71) and, where a host adopts confirmation, IEmailConfirmableUser (matched inCheckEmailConfirmed), ITokenService (reached only through the issuer'sTokenServiceproperty,MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Sessions/IAuthSessionIssuer.cs:34), TwoFactorOutcome, TwoFactorErrors and AuthClaimTypes (themfaclaim values), Email (normalizing the login and register address), ClaimsPrincipalExtensions (principal.GetUserId()andprincipal.FindMultiFactorMethod()), Result and Error, and the request and response DTOs. The base itself takes no session store, no session settings and no clock (:63-70): those belong to the issuer.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 (:19-23) 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, through IAuthSessionIssuer, 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 division of labour is the other concept to absorb before reading the code (
:39-44): this class decides who is signed in, and IAuthSessionIssuer decides what they are handed. Once a caller is proved, the multi-device refresh sessions (hashed at rest), BR-205 rotation with BR-206 reuse detection, the per-user session cap, and thesidandmfaclaims on the minted access token are the issuer's job, so this workflow never touches a RefreshSession row. Every session-shaped member below is a single delegation to the issuer; the reuse, replay, expiry and cap rules are taught on AuthSessionIssuer.Walkthrough (members in teaching order):
Constructor and protected accessors (
:64-71): a primary constructor takes the five required collaborators plus the two optional ones. A protectedCurrentTermsVersion(:120) forwardsvalidators.CurrentTermsVersion, so adopting Terms of Service acceptance adds no constructor dependency here or in the app's subclass; when it is non-null,RegisterAsynchas already refused a request whoseAcceptedTermsis false (:236, returningLegalAcceptanceErrors.TermsNotAccepted) beforeCreateUserruns, so aCreateUseroverride stamps that version on the new user (doc comment:105-119). An external-login path that creates users outsideRegisterAsyncleaves them unstamped, and the UI's acceptance gate asks them on first sign-in. A private_multiFactorMethodfield (:83) is the base's own arming state for the second-factor claim, a plain field because the service is scoped and one request issues one token pair (remarks,:79-82); it is handed to the issuer at mint time. Protected read-only properties re-exposeUnitOfWork(:86),TokenService(:98), which returnssessionIssuer.TokenService, the issuer's stamping instance, so minting through it is what putssidon the token (remarks,:88-97), and aRepositoryresolved lazily asunitOfWork.GetRepository<TUser, UserIdentifierType>()(:101-102). Token lifetimes and the session cap are not members of this class: they are configuration read by the issuer.LoginAsync(:105-200): validate the request, check lockout (ADR-029 and BR-212), normalize the raw email into an Email value object 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 to verify credentials without change-tracker overhead. The null check now also callsHasStoredCredential(untracked)(:135, private helper at:581-582): an account with no stored password material, the shape an external-OAuth account carries (ADR-036), can never be reached by password login, and both branches answer the identical generic 401 with the identicalBurnPasswordVerificationCost(request.Password)call (:137, private helper at:589-599) so the two cases cost the same time and are indistinguishable to a timing observer, beforeIncrementFailedAttemptsAsync.passwordHasher.VerifyPasswordruns next and fails the same way on a wrong password. Only after the password check does the app gate (ValidateLoginCandidateAsync) run, deliberately reordered: reaching it now proves the caller owns the account, so its distinct status message (for example a deactivated-account rejection) is told to the account's owner rather than to anyone sweeping addresses, and running it before the password check (the old order) made account state readable with no credential at all. Two more gates follow the same rule, for the same reason:CheckEmailConfirmed(untracked)(:164, hook at:480-490), off unless the host both suppliedEmailConfirmationSettings.RequireConfirmedEmailand the app'sUserimplements IEmailConfirmableUser, andChallengeSecondFactorCountingFailuresAsync(untracked.Id, request, cancellationToken)(:170-171, private helper at:522-535). That helper runs the overridableChallengeSecondFactorAsynchook (:504-510), which with no ITwoFactorAuthenticator registered answersTwoFactorOutcome.NotEnrolledoutright at no extra cost, and counts aTwoFactorErrors.TwoFactorInvalidCodefailure against the account throughIncrementFailedAttemptsAsyncexactly like a wrong password, so the lockout at the top of the method throttles code guessing per account. A missing code is the ordinary first leg of the challenge and is not counted (:512-517). Step 2 is a tracked re-fetch by id, 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, and_multiFactorMethod = MultiFactorMethodFor(secondFactor.Value)(:191, private helper at:543-552, mapping a verified TOTP or recovery code to themfaclaim value and any unrecognized outcome tonull) arms the field in atry/finallyaroundIssueTokensAsyncso the claim lands only on this response and disarms whether or not the call throws.RegisterAsync(:203-280): validate, IP rate-limit (ADR-029 and BR-213), reject a duplicate email through theEmailExistsAsynchook, hash the password, build the user through theCreateUserhook,AddAsyncandSaveChangesAsync, run theOnUserRegisteredAsyncpost-commit hook to pick up the instance the first access token is minted from, increment the IP registration count, and open the session last: 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)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: 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 and still reaches the exception middleware. The re-check passesCancellationToken.Noneon purpose: it has to run even when the caller's token is what aborted the save, or a cancelled save could never be classified.RefreshTokenAsync(:283-349): validate, pull claims from the expired JWT viaTokenService.GetPrincipalFromExpiredToken(:297, the issuer's instance; signature still checked, only lifetime skipped), read the identifier withprincipal.GetUserId()(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, run the refresh app gate, and hand the presented refresh token tosessionIssuer.RotateAsync(:337-343) together with a mint callback overCreateAccessTokenForSession. The issuer resolves the session behind the token (reuse detection included), rotates it, and answers with a token pair whose access token carries the successor'ssid, a client's current-device marker following the rotation instead of pointing at the session the rotation just revoked. The step-up the user already performed is carried across the rotation too:_multiFactorMethod = principal.FindMultiFactorMethod()(:332) reads themfaclaim off the presented access token, whose signature was already validated, and arms it in atry/finallyaround the successor mint, the same disarm pattern asLoginAsync. Dropping it would quietly demote a signed-in session every access-token lifetime and make anIRequiresMfause case unreachable without a fresh sign-in.RevokeTokenAsync(:352-366): load the user (aNotFoundwhen absent), then delegate tosessionIssuer.SignOutAsync(userId, refreshToken, ...)(:363). Only a live session belonging to this user identifies the device to sign out; when the token is absent or identifies no live session of this user, the issuer revokes every live session instead (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Sessions/IAuthSessionIssuer.cs:88-97), rather than reporting success for a revocation that reached nothing.RevokeAllSessionsAsync(:369-382): the unconditional form of the same thing, throughsessionIssuer.SignOutEverywhereAsync(:379).GetSessionsAsync(:386-394): no user lookup, so a list is one call tosessionIssuer.ListActiveAsync(userId, currentSessionId, ...)(:391) wrapped in a success. The issuer lists live sessions newest first and leaves expired-but-unrevoked rows out, since a device list must not offer a device that can no longer authenticate (IAuthSessionIssuer.cs:105-116).RevokeSessionByIdAsync(:402-406): an expression-bodied delegation tosessionIssuer.RevokeSessionAsync. Ownership is checked by the issuer's store query, so another account's id and a nonexistent id produce the sameNotFound; an already-revoked session writes nothing and returnsNotFoundwithAuth.SessionAlreadyRevoked, matching the interface contract (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/IAuthenticationService.cs:107-109).IssueTokensAsync(:417-431): the shared open-and-respond used by login and registration, and reusable by an app-level external-login flow. It null-guards the user and delegates tosessionIssuer.IssueAsync(user.Id, sessionId => CreateAccessTokenForSession(user, sessionId), ...)(:425-430): the access token is a callback because it carries the session's id, which exists only once the issuer has opened the session.The private mechanics are login-side only.
ChallengeSecondFactorCountingFailuresAsync(:522-535) is the counting wrapper described underLoginAsync.MultiFactorMethodFor(:543-552) maps an unrecognized outcome tonull, so an enum that grew cannot silently mint anmfaclaim.HasStoredCredential(:581-582) requires both a non-empty hash and a non-empty salt.BurnPasswordVerificationCost(:589-599) runs one throwawayVerifyPasswordagainst a zeroed 64-byte hash and 32-byte salt, skipping a blank password, so a branch that never reaches the real check still pays the key-derivation cost.EmailAlreadyExistsFailure()(:606-608) is the 409 shared by the up-front check and the race recovery so the two paths are indistinguishable to a caller. Session resolution, rotation, family revocation and the cap are private to AuthSessionIssuer.The hooks: four are
abstract, so a subclass must supply them.FindUntrackedByEmailAsync(:438) andEmailExistsAsync(:444) 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);CreateUser(:447) runs the app's domain factory;CreateAccessToken(:450) mints the app's claim set (for examplespeaker_idversuscustomer_id). Sevenvirtualmembers can be overridden:CreateAccessTokenForSession(:469-470) callssessionIssuer.MintForSession(sessionId, _multiFactorMethod, () => CreateAccessToken(user)), so the issuer arms its stamping token service for the duration of the app hook andsid(plusmfawhen this request verified a second factor) is appended to whatever claim set the app passes, which is what keeps the claims additive with no change toCreateAccessToken's signature (remarks,:456-465);CheckEmailConfirmed(:480-490) is the email-confirmation sign-in gate, off unless the host suppliedEmailConfirmationSettings.RequireConfirmedEmailand the app'sUserimplements IEmailConfirmableUser;ChallengeSecondFactorAsync(:504-510) runs the second-factor challenge and, with no ITwoFactorAuthenticator injected, answersTwoFactorOutcome.NotEnrolledwith no extra query, which is what keeps the feature free for an app that has not adopted it;ValidateLoginCandidateAsync(:555) andValidateRefreshCandidateAsync(:559) add extra gates such as a deactivated-account check;OnUserRegisteredAsync(:566) runs the post-commit side-effect; andCreateRefreshUserMissingError(:574) 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), and keeping them behind IAuthSessionIssuer gives the sign-in decision and the session mechanics one owner each (
:39-44). Password material flows through IAuthUser'sPasswordHashandPasswordSalt(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:50, which bindsTUser = User, adds the Attendee default role (BR-45) and thespeaker_idclaim (BR-209, built at:381-384), passes the injected IAuthSessionIssuer through to the base (:56,:63), and re-listsIAuthenticationService(:66) 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:30-42). 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,:37-38).
IssuedSession
MMCA.Common.Application ·
MMCA.Common.Application.Auth.Sessions·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Sessions/AuthSessionIssuer.cs:532· 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 AuthSessionIssuer (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Sessions/AuthSessionIssuer.cs:532), 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 atAuthSessionIssuer.cs:157and:285to 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 (AuthSessionIssuer.cs:527-531): the plaintext "exists nowhere else, since the store keeps only its hash". Modelling the hand-off as a small record rather than an out-parameter or a tuple is what keeps that fact readable: both methods that can produce a token returnResult<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)(AuthSessionIssuer.cs:532).RefreshTokenis what goes back to the caller in the AuthenticationResponse;SessionIdis what the access token'ssidclaim carries, which is why the session must be opened before the token is minted (the comment atAuthSessionIssuer.cs:75-76). - 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(AuthSessionIssuer.cs:352, constructed at:337) andRotateSessionAsync(:350, constructed at:390); unwrapped byIssueAsync(:84-85) and byRotateAsync(:120-121), each of which passesSessionIdto the caller'smintAccessTokencallback and putsRefreshTokeninto the response.
EmailConfirmationSettings
MMCA.Common.Application ·
MMCA.Common.Application.Auth.EmailConfirmation·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/EmailConfirmation/EmailConfirmationSettings.cs:10· Level 0 · class
- What it is: the options class bound to the
Authentication:EmailConfirmationconfiguration section (EmailConfirmationSettings.cs:28), controlling token lifetime, throttling, and whether an unconfirmed address blocks sign-in. - Depends on: nothing first-party;
System.ComponentModel.DataAnnotations([Range]) andSystem.Diagnostics.CodeAnalysis([SuppressMessage]) validate the bound values. - Concept introduced: the config-bound settings-class shape (a
SectionNameconstant plusinit-only, range-validated properties with sane defaults) is the same one the siblingPasswordResetSettingsuses elsewhere in this group; see it there for the general pattern rather than re-teaching it here.[Rubric §11, Security](assesses whether authentication-adjacent behavior is safely configurable): every numeric knob here is range-constrained (EmailConfirmationSettings.cs:43,50,54,58), so a bad value in configuration fails validation instead of silently disabling throttling. - Walkthrough:
ConfirmationUrl(EmailConfirmationSettings.cs:40) defaults to empty rather than being[Required], deliberately, per its own doc comment (EmailConfirmationSettings.cs:30-34): an unconfigured host still boots and degrades to a token-only, paste-in-by-hand flow.TokenLifetimeMinutes(line 44) defaults to 1440 (one day).MaxValidationAttempts(line 51) caps wrong-token guesses at 5 before the record is discarded.MaxRequestsPerEmail(line 55) andRequestWindowMinutes(line 59) throttle re-issuance per address.RequireConfirmedEmail(line 70) defaults tofalse. - Why it's built this way: the
RequireConfirmedEmaildefault is called out as load-bearing in its own remarks (EmailConfirmationSettings.cs:49-54): turning it on locks out every account whose address was never confirmed, so a host backfills existing rows as confirmed before flipping it, and with it off the whole feature is additive. This is the opt-in shape recorded in ADR-116. - Where it's used: read by
AuthenticationServiceBaseandEmailConfirmationTokenService(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs,MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/EmailConfirmationTokenService.cs) and registered inMMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs; ADC binds it viaMMCA.ADC.Identity.Application/Users/AuthenticationServiceSettings.csand consumes it inSendEmailConfirmationHandler.
IPermissionGrantCache
MMCA.Common.Application ·
MMCA.Common.Application.Auth.Permissions·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Permissions/IPermissionGrantCache.cs:21· Level 0 · interface
- What it is: the read side of the in-memory snapshot of stored (database-granted, as opposed to compiled-into-code) permissions, keyed by role.
- Depends on: nothing first-party in its own signature; consumed alongside
IPermissionRegistry. - Concept introduced: the cache-in-front-of-a-store pattern used across this group for data that is read on every authorization check but changes rarely.
[Rubric §12, Performance & Scalability](assesses whether hot paths avoid a database round trip):GetPermissions(IPermissionGrantCache.cs:29) is synchronous and reads the already-loaded snapshot, so it stays off the authorization hot path's I/O. - Walkthrough:
GetPermissions(string role)(line 99) returns the role's stored permissions, matched case-insensitively, empty when the role has none or the cache has not loaded yet.RefreshAsync(line 107) reloads the whole snapshot from the store; called at startup, on a configured interval, and explicitly by IPermissionGrantCacheInvalidator after a grant changes. - Why it's built this way: separating the read interface from the invalidation interface (IPermissionGrantCacheInvalidator) lets LayeredPermissionRegistry, which only needs to read, depend on a narrower contract than the administration path that needs to force a reload.
- Where it's used: implemented by
PermissionGrantCache(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Auth/PermissionGrantCache.cs), read by LayeredPermissionRegistry (LayeredPermissionRegistry.cs) andStoredPermissionRoleAdministrationService, and registered inMMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs.
IPermissionGrantCacheInvalidator
MMCA.Common.Application ·
MMCA.Common.Application.Auth.Permissions·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Permissions/IPermissionGrantCache.cs:53· Level 0 · interface
- What it is: the write-triggering half of the cache: forces a reload of one role's grants (or every role) after an administrative change.
- Depends on: nothing first-party in its own signature.
- Concept: same cache-invalidation split introduced at IPermissionGrantCache; no new concept here.
- Walkthrough:
InvalidateAsync(string? role = null, ...)(IPermissionGrantCache.cs:61) invalidates the cached grants and reloads them;nullmeans every role. - Why it's built this way: kept on a separate, narrower interface from the read side so a caller that only grants or revokes (the administration surface) depends on exactly the capability it needs.
- Where it's used: implemented alongside
IPermissionGrantCachebyPermissionGrantCache.cs, called byStoredPermissionRoleAdministrationService.csafter a grant or revoke, and registered inMMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs.
PermissionGrantSettings
MMCA.Common.Application ·
MMCA.Common.Application.Auth.Permissions·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Permissions/PermissionGrantSettings.cs:9· Level 0 · class
- What it is: the options class bound to
Authentication:PermissionGrants, controlling how long a cached grant snapshot is served, which logical data source owns the grant table, and which role names the administration surface lists. - Depends on: nothing first-party;
System.ComponentModel.DataAnnotations([Range],[Required]). - Concept: the same config-bound settings shape as EmailConfirmationSettings; no new concept.
- Walkthrough:
SectionName(PermissionGrantSettings.cs:12) is"Authentication:PermissionGrants".CacheSeconds(line 25) defaults to 300 (five minutes), is range-validated to 5-3600 (line 24), and its remarks (lines 17-23) spell out two things. First the trade-off: this is the bound on how stale another replica may be after an edit, since invalidation is per process. Second the failure behavior: each cached entry lives three intervals, so the snapshot outlives the gap before the next reload and survives up to two failed reloads, after which it stops answering and stored grants fail closed.DataSourceName(line 33,[Required]at line 32) defaults to"Default", naming the logical data source a modular host points at its Identity database.KnownRoles(line 45) defaults to an empty list, and its remarks (lines 39-44) explain why it must be configured: neitherIPermissionRegistry(which answers questions about a role but does not list roles) nor the grant table (which only knows roles that already have a grant) can enumerate roles on its own; left empty, the surface still works but lists nothing until the first grant exists. - Why it's built this way: a five-minute cache treats a permission grant as an administrative change rather than a per-request one, trading a shorter staleness window for more database reads if lowered. The three-interval entry lifetime means a transient store outage costs nothing for two reload cycles, while a longer one degrades to denying stored grants (compiled permissions are unaffected, see LayeredPermissionRegistry) rather than serving an arbitrarily old snapshot.
- Where it's used: read by
PermissionGrantCache.cs,PermissionGrantRefreshService.cs,EFPermissionGrantStore.cs,StoredPermissionRoleAdministrationService.cs,ApplicationDbContext.cs, andDesignTimeDbContextHelper.cs(all underMMCA.Common/Source/Core/MMCA.Common.Infrastructure); registered inMMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Auth.cs; pinned byPermissionGrantModelGateTests.csandPermissionGrantCacheTests.csinMMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests.
LegalAcceptanceOptions
MMCA.Common.Application ·
MMCA.Common.Application.Auth.Legal·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Legal/LegalAcceptanceOptions.cs:12· Level 0 · class (sealed)
- What it is: the options class for terms-of-service acceptance, bound from the
Legalsection (SectionName,LegalAcceptanceOptions.cs:15). Its one setting,CurrentTermsVersion(:21), is the terms version every user must have accepted (for example2026-10-01); changing it asks every signed-in user to accept again, and null or whitespace turns the feature off. - Depends on: nothing; it is a plain options bag.
- Concept: a feature switch carried by configuration rather than a flag. "Not configured" is a legal state that disables the feature, and LegalAcceptancePolicy
.ResolveCurrentVersionis the single place that turns the raw value into a trimmed version or null. - Why it's built this way: an opt-in host (ADR-116) that never sets the section changes nothing, so the registration only binds the section (
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Auth.cs:109-110, insideAddLegalAcceptanceat:104). Related: ADR-070, ADR-116. - Where it's used: injected as
IOptions<LegalAcceptanceOptions>by LegalAcceptanceControllerBase (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Legal/LegalAcceptanceControllerBase.cs:48, read at:54) and, optionally, by AuthenticationValidators (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationValidators.cs:28,43), which exposesCurrentTermsVersionto the registration check.
SessionStampingTokenService
MMCA.Common.Application ·
MMCA.Common.Application.Auth.Sessions·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Sessions/AuthSessionIssuer.cs:544· Level 1 · class (private sealed, nested)
- What it is: a pass-through ITokenService that appends the current refresh session's
sidclaim and, when a second factor verified this request, anmfaclaim 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 AuthSessionIssuer (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Sessions/AuthSessionIssuer.cs:544). - Depends on: ITokenService (the contract it implements and the inner instance it wraps,
AuthSessionIssuer.cs:544), AuthClaimTypes for thesidandmfaclaim names (:484,:489), 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 need to name the session they belong to (and, when relevant, the second factor that satisfied a step-up), but the claim set is produced by the app's ownCreateAccessTokenhook. The obvious fix, adding parameters to that hook, is a compile break in every consumer for claims the app has no decision to make about; the type's own remarks say exactly this (AuthSessionIssuer.cs:539-542). Wrapping the token service instead means the issuer arms the wrapper around the hook call and the claims appear 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, twice over. - Walkthrough: a primary constructor takes the
innerservice (AuthSessionIssuer.cs:544).Guid? CurrentSessionId { get; set; }(:451) is the session arming switch: a session id stamps,nullmints unchanged.string? CurrentMultiFactorMethod { get; set; }(:457) is the second-factor arming switch, set only when a second factor really verified for this request. The remarks on the arming method explain why plain mutable properties are safe here (:126-129): the issuer is resolved per request (scoped, like the store it saves through) and one request mints one token at a time.AccessTokenLifetime(:460) andRefreshTokenLifetime(:463) forward straight toinner, so the lifetime callers read is still the JWT settings' value.GenerateAccessToken(...)(:466-493) is the only member with behavior. When neither switch is armed it delegates verbatim (:473-476). Otherwise it copies the app'sadditionalClaimsinto a newList<Claim>(:478, so the caller's sequence is never mutated), then independently appendsAuthClaimTypes.SessionIdformatted assessionId.ToString("D", CultureInfo.InvariantCulture)whenCurrentSessionIdis set (:480-485, the"D"format being the canonical hyphenated Guid formClaimsPrincipalExtensions.FindSessionIdparses back, see ClaimsPrincipalExtensions) andAuthClaimTypes.MultiFactorset to the raw method string whenCurrentMultiFactorMethodis set (:487-490), before delegating with the extended list (:492). The two claims are independent: a token can carry either, both, or neither.GenerateRefreshToken()(:496) andGetPrincipalFromExpiredToken(string token)(:499-500) are plain forwards.
- Why it's built this way: putting the stamping behind an
ITokenServicerather than inside the caller's own method keeps the app hook's signature and semantics untouched while still guaranteeing both claims on the tokens the framework's own flows mint. The escape hatch still applies: an app that mints from its own injectedITokenServicereference produces a valid token with neither claim, and can restore them by overridingCreateAccessTokenForSession(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:495-496). - Where it's used: constructed once per issuer instance (
AuthSessionIssuer.cs:52) and exposed as IAuthSessionIssuer.TokenService(:53), which AuthenticationServiceBase<TUser> surfaces to subclasses as its protectedTokenServiceproperty (AuthenticationServiceBase.cs:99, whose remarks at:88-97state that minting through the property is what putssidon the token). Both switches are armed and disarmed byAuthSessionIssuer.MintForSession(AuthSessionIssuer.cs:132-147, with thefinallyat:140-144guaranteeing both disarm even when the hook throws), which the base calls fromCreateAccessTokenForSessionwith its_multiFactorMethodfield (AuthenticationServiceBase.cs:495-496).
LayeredPermissionRegistry
MMCA.Common.Application ·
MMCA.Common.Application.Auth.Permissions·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Permissions/LayeredPermissionRegistry.cs:30· Level 1 · class
- What it is: an
IPermissionRegistrydecorator that unions the compiled-into-code permission set with the stored (database-granted) set, so a role's effective permissions are "compiled OR granted", never a replacement of one by the other. - Depends on:
IPermissionRegistry(the inner, compiled registry), IPermissionGrantCache (the stored-grant cache). - Concept introduced: the decorator pattern applied to a registry lookup.
[Rubric §2, Design Patterns](assesses whether a pattern is used idiomatically and solves a real problem):LayeredPermissionRegistrywrapsinnerwithout changing its contract, adding a second data source transparently to every caller ofIPermissionRegistry. - Walkthrough: the type is a primary-constructor class taking
innerandgrants(LayeredPermissionRegistry.cs:30-32).GetPermissions(role)(lines 35-52) reads both layers; when the stored set is empty it returns the compiled set as-is (no allocation), and only when both layers contribute does it materialize aHashSet<string>union, per the comment at lines 45-47: this call is a reporting path (administration surface, diagnostics), not the hot authorization path.HasPermission(roles, permission)(lines 55-66) is that hot path: it short-circuits on the compiled registry first (inner.HasPermission), and only falls through to a per-role stored-grant lookup (grants.GetPermissions(role).Contains(permission)) when the compiled check fails; the caller'srolessequence is materialized once (line 62) because both passes would otherwise enumerate it twice. - Why it's built this way: separating the always-cheap compiled check from the stored-grant fallback keeps the common case (a permission granted in code) allocation-free and single-pass, per ADR-116, which records the opt-in database-grant layer as additive on top of the existing compiled registry.
- Where it's used: registered as the
IPermissionRegistryimplementation inMMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs(3 sites) when database grants are enabled; exercised directly inMMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Auth/LayeredPermissionRegistryTests.cs.
EmailConfirmationErrors
MMCA.Common.Application ·
MMCA.Common.Application.Auth.EmailConfirmation·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/EmailConfirmation/EmailConfirmationErrors.cs:9· Level 2 · class
- What it is: the static
Error-factory class for the email-confirmation flow: one factory for a sign-in refused because the address is unconfirmed, one for every failing token redemption. - Depends on:
Error(the Result-pattern error type taught in the primer). - Concept: the same named-code-plus-factory-method convention used by every
*Errorsclass in this codebase (see AuthErrorCodes for the general shape); no new concept here. - Walkthrough:
EmailNotConfirmedCode(EmailConfirmationErrors.cs:12) andInvalidTokenCode(line 15) are the two error codes.EmailNotConfirmed(source)(lines 25-28) returnsError.Unauthorized, distinct fromAuth.InvalidCredentialsso a UI can offer to resend the link.InvalidToken(source)(lines 33-36) is the single rejection every failing redemption (unknown, expired, mismatched, or attempt-capped token) collapses to. - Why it's built this way: the doc comment on
EmailNotConfirmed(lines 474-478) explains it is reachable only after the password has already been proved, so the message can safely tell the account owner what is wrong rather than help an address sweeper enumerate valid accounts;InvalidTokencollapsing every failure mode to one message denies an attacker any signal about which reason a redemption failed for. - Where it's used: raised by
ConfirmEmailHandlerBase.cs(2 sites) andAuthenticationServiceBase.cs, and byEmailConfirmationTokenService.cs; asserted against inEmailConfirmationHandlerBaseTests.cs,EmailConfirmationTokenServiceTests.cs, and ADC'sConfirmEmailHandlerTests.cs.
LegalAcceptanceErrors
MMCA.Common.Application ·
MMCA.Common.Application.Auth.Legal·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Legal/LegalAcceptanceErrors.cs:11· Level 2 · class (static)
- What it is: the two validation errors of terms acceptance, as factory methods plus their code constants.
- Depends on:
Error(the primer's Result/Error pattern), AuthErrorCodes (TermsNotAcceptedCodealiasesAuthErrorCodes.TermsNotAccepted,LegalAcceptanceErrors.cs:14) and LegalAcceptanceErrorCodes (VersionNotCurrentCode,:17). - Concept: error factories with stable codes, mirroring EmailConfirmationErrors; no new concept.
- Walkthrough:
TermsNotAccepted(source)(:22) is the registration refusal when a terms version is configured and the box was not ticked ("You must accept the Terms of Service to register.").VersionNotCurrent(source)(:33) refuses an acceptance of any version other than the configured one, including any acceptance while none is configured. Both buildError.Validationwith the caller-supplied source. - Why it's built this way: the shared code constants let a client switch on a stable string without referencing the Application layer.
- Where it's used:
TermsNotAcceptedis returned byAuthenticationServiceBase.RegisterAsync(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:238);VersionNotCurrentby LegalAcceptancePolicy.EnsureAcceptsCurrentVersion(LegalAcceptancePolicy.cs:36).
IAuthSessionIssuer
MMCA.Common.Application ·
MMCA.Common.Application.Auth.Sessions·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Sessions/IAuthSessionIssuer.cs:26· Level 3 · interface
- What it is: the contract that issues, rotates and revokes the access/refresh token pair behind a signed-in device. Its summary (
IAuthSessionIssuer.cs:7-12) lists what it owns: the multi-device RefreshSession rows (hashed at rest), BR-205 rotation with BR-206 reuse detection, the per-user live-session cap, and thesid/mfaclaims stamped on the access token minted for a session. - Depends on: ITokenService, AuthenticationResponse, RefreshSessionSummaryResponse,
Result/Result<T>(the Result pattern taught in the primer), and theUserIdentifierTypealias. - Concept introduced: splitting "who is signed in" from "what they are handed".
[Rubric §1, SOLID]assesses whether each type has one reason to change. The remarks (IAuthSessionIssuer.cs:15-19) draw the line explicitly: credentials, lockout, the second factor and the app's own gates stay with AuthenticationServiceBase<TUser>, which calls the issuer only once a caller has been proved, and the base's own summary states the same split from the other side (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:41-44: "This class decides who is signed in; IAuthSessionIssuer decides what they are handed"). The access token's claim set still belongs to the app, so the issuer takes it as aFunc<Guid, string> mintAccessTokencallback: the session id the token must carry exists only once the session has been opened or rotated.[Rubric §11, Security]assesses credential lifecycle handling; the rotation and reuse rules are stated on the contract itself, not left to an implementation. - Walkthrough:
TokenService(IAuthSessionIssuer.cs:34): the ITokenService an app mints through. While the issuer is minting for a session it appendssid(andmfawhen a second factor verified); at any other time it is the plain registered service (doc at:29-32).IssueAsync(userId, mintAccessToken, ipAddress, userAgent, ct)(:46): opens a new session, evicting the oldest live one when the per-user cap is full, persists it and returns the pair; the user's other sessions are untouched (:37-38).RotateAsync(userId, refreshToken, mintAccessToken, ipAddress, userAgent, ct)(:70): BR-205 rotation. An unknown or expired token fails alone, and so does a token whose session was revoked by a sign-out or evicted by the session cap (that device simply lost its session). A token already rotated, already flagged as reuse, or rotated first by a concurrent request is the BR-206 reuse signal and revokes every live session the user holds; a revoked row with any other or no recorded reason is treated as reuse too. Every rejection carries the sameAuth.InvalidRefreshTokenerror (:54-61).MintForSession(sessionId, multiFactorMethod, mintAccessToken)(:86): runs the callback withTokenServicearmed to stampsidand, when not null,mfa.SignOutAsync(userId, refreshToken, ct)(:97): revokes the one live session behind the presented token when it belongs to the user; when the token is absent or identifies no live session of this user, every live session is revoked instead (:89-91).SignOutEverywhereAsync(userId, ct)(:103): revokes every live session and saves.ListActiveAsync(userId, currentSessionId, ct)(:113): live sessions newest first, with expired-but-unrevoked rows left out because a device list must not offer a device that can no longer authenticate (:106-107).RevokeSessionAsync(userId, sessionId, ct)(:128): another account's session id and a never-existing id both answerAuth.SessionNotFound; an already-revoked session writes nothing and answers the not-found failureAuth.SessionAlreadyRevoked, so the client can say the device was already signed out (:119-122).
- Why it's built this way: the remarks (
IAuthSessionIssuer.cs:22-23) give the lifetime rule: scoped, like the session store it writes through, so a sign-in and its session insert share the request's unit of work. Keeping the session rules behind one interface means the authentication workflow "never touches a session row" (AuthenticationServiceBase.cs:44). The multi-device session model is recorded in ADR-097. - Where it's used: implemented by AuthSessionIssuer; registered
TryAddScopedinMMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:176; injected into AuthenticationServiceBase<TUser> (AuthenticationServiceBase.cs:69), which delegates to it fromTokenService(:98),RefreshTokenAsync(:337), sign-out (:363,:379), session listing (:391) and revocation (:406),IssueTokensAsync(:425) andCreateAccessTokenForSession(:470). ADC'sAuthenticationServicetakes it as a constructor argument and passes it to the base (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/AuthenticationService.cs:56,64).
IEmailConfirmationTokenService
MMCA.Common.Application ·
MMCA.Common.Application.Auth.EmailConfirmation·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/EmailConfirmation/IEmailConfirmationTokenService.cs:15· Level 3 · interface
- What it is: the token-lifecycle abstraction for email confirmation: issue a token for an address, validate and consume one presented back.
- Depends on:
UserIdentifierType(the module's identifier-type alias),Result<T>. - Concept: an issue/validate-and-consume token contract, mirroring the shape
IPasswordResetTokenServiceuses elsewhere in this group; no new concept. - Walkthrough:
IssueAsync(email, userId, cancellationToken)(IEmailConfirmationTokenService.cs:28) replaces any token already outstanding for that address, so there is one active token per address, and fails only when the per-address request throttle (EmailConfirmationSettings.MaxRequestsPerEmail/RequestWindowMinutes) has been exceeded.ValidateAndConsumeAsync(email, token, cancellationToken)(line 41) validates against the outstanding token and consumes it on success so it never redeems twice, collapsing unknown/expired/mismatched/attempt-capped failures to the single EmailConfirmationErrors.InvalidTokenerror. - Why it's built this way: consuming on success is what makes redemption single-use; collapsing every failure reason to one error, per its own doc comment (lines 603-606), denies an attacker signal about why a guess failed.
- Where it's used: implemented by
EmailConfirmationTokenService(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/EmailConfirmationTokenService.cs), consumed bySendEmailConfirmationHandlerBase.csandConfirmEmailHandlerBase.cs, and by ADC'sSendEmailConfirmationHandler.cs/ConfirmEmailHandler.cs; registered inMMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs.
ILegalAcceptanceService
MMCA.Common.Application ·
MMCA.Common.Application.Auth.Legal·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Legal/ILegalAcceptanceService.cs:21· Level 3 · interface
- What it is: the per-user storage contract behind terms acceptance: read the signed-in user's standing, and record that they accepted a version.
- Depends on: LegalAcceptanceDTO,
Result<T>, and theUserIdentifierTypealias. - Concept: a deliberately thin consumer boundary. The controller base validates the request first (
ILegalAcceptanceService.cs:14, via LegalAcceptancePolicy.EnsureAcceptsCurrentVersion) and re-derives every answer afterwards (:17, viaNormalize), so an implementation only has to load and save the accepted version and instant. - Walkthrough:
GetForCurrentUserAsync(currentUserId, currentVersion, ct)(:26) reads the caller's standing, or a failure such as NotFound.AcceptForCurrentUserAsync(currentUserId, version, ct)(:39) records the acceptance stamped with the implementation's own clock and returns the standing afterwards. - Why it's built this way: Common ships no implementation and
AddLegalAcceptancedeliberately registers none (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Auth.cs:93), because where the accepted version lives is the consumer'sUseraggregate (seeILegalAcceptingUserinMMCA.Common/Source/Core/MMCA.Common.Domain/Auth/ILegalAcceptingUser.cs). Recorded in ADR-116. - Where it's used: consumed by LegalAcceptanceControllerBase (
MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Legal/LegalAcceptanceControllerBase.cs:46); ADC implements it asLegalAcceptanceService(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/LegalAcceptanceService.cs), registers it in that module'sDependencyInjection.cs, and subclasses the controller base asLegalAcceptanceController.
LegalAcceptancePolicy
MMCA.Common.Application ·
MMCA.Common.Application.Auth.Legal·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Legal/LegalAcceptancePolicy.cs:10· Level 3 · class (static)
- What it is: the pure rules of terms acceptance, kept out of the controller and the consumer's service so they cannot drift.
- Depends on: LegalAcceptanceOptions, LegalAcceptanceErrors, LegalAcceptanceDTO,
Result. - Concept: server-authoritative version checking.
[Rubric §11, Security]assesses whether consent is recorded against what the user actually saw: the client says which version it showed, and the server refuses unless it equals the configured one. - Walkthrough:
ResolveCurrentVersion(options)(LegalAcceptancePolicy.cs:18) returns the trimmed configured version, or null for null options, null value or whitespace.EnsureAcceptsCurrentVersion(currentVersion, suppliedVersion, source)(:36) succeeds only when a version is configured and the trimmed supplied one matches it with an ordinal comparison; otherwise it fails withLegalAcceptanceErrors.VersionNotCurrent.Normalize(currentVersion, standing)(:50) re-derives the standing throughLegalAcceptanceDTO.Evaluate, taking only the accepted version and instant from the consumer's answer, so the result never depends on a consumer fillingCurrentVersionorIsCurrentcorrectly. - Why it's built this way: the ordinal match means a client holding stale terms cannot record consent to the new ones, and the no-version-configured failure means there is nothing to accept when the feature is off.
- Where it's used: LegalAcceptanceControllerBase (
LegalAcceptanceControllerBase.cs:54,83,110,125), AuthenticationValidators (AuthenticationValidators.cs:43), andLegalAcceptancePolicyTests.cs(MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Auth/Legal/).
IPermissionGrantStore
MMCA.Common.Application ·
MMCA.Common.Application.Auth.Permissions·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Permissions/IPermissionGrantStore.cs:16· Level 4 · interface
- What it is: the persistence abstraction behind the stored-grant layer: read every grant, read one role's grants, grant a permission, revoke a permission.
- Depends on:
PermissionGrant(the persisted grant row),Result. - Concept: an idempotent grant/revoke pair, so a click repeated by an administration UI is not an error; no new concept beyond that idempotency guarantee.
- Walkthrough:
GetAllAsync(cancellationToken)(IPermissionGrantStore.cs:23) reads every stored row, used to build the in-memory snapshot the authorization path reads.GetPermissionsAsync(role, cancellationToken)(line 31) reads one role's stored permissions for the administration surface.GrantAsync(role, permission, grantedBy, cancellationToken)(lines 42-46) grants a permission, succeeding and writing nothing when the role already has it stored (doc comment lines 33-36).RevokeAsync(role, permission, cancellationToken)(line 60) removes a stored grant, also idempotent; its remarks (lines 51-55) are explicit that this removes only the STORED grant, never a compiled-into-code one, which is exactly what keeps a data edit from disabling an endpoint the code guarantees. - Why it's built this way: the "stored grant is additive, never subtractive over compiled permissions" rule enforced here is the same invariant LayeredPermissionRegistry.
HasPermissionimplements: the compiled check always wins first, and revoking a stored grant can never take away a compiled-in permission. - Where it's used: implemented by
EFPermissionGrantStore(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Auth/EFPermissionGrantStore.cs), read byPermissionGrantCache.cs(2 sites) to build the IPermissionGrantCache snapshot, called by IRoleAdministrationService's implementationStoredPermissionRoleAdministrationService.cs(2 sites), and registered inMMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs.
AuthSessionIssuer
MMCA.Common.Application ·
MMCA.Common.Application.Auth.Sessions·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Sessions/AuthSessionIssuer.cs:41· Level 6 · class (sealed)
- What it is: the framework's IAuthSessionIssuer: multi-device refresh sessions, hashed at rest (
AuthSessionIssuer.cs:12-14). Every issue opens its own RefreshSession, so signing in on a second device leaves the first one signed in. - Depends on: ITokenService (mints both tokens), IRefreshSessionStore (the session rows),
IOptions<RefreshSessionSettings>(the per-user cap),TimeProvider(every session instant), all as primary-constructor parameters (AuthSessionIssuer.cs:41-45); RefreshSession, AuthenticationResponse, RefreshSessionSummaryResponse andError/Resultfrom the primer. Its two private helpers are IssuedSession and SessionStampingTokenService. - Concept introduced: refresh-token rotation with family revocation on reuse, and a grace window for races.
[Rubric §11, Security]assesses how bearer credentials are issued, stored and revoked. The class remarks (AuthSessionIssuer.cs:17-29) state the model: the store holds onlyRefreshSession.HashTokendigests; rotation revokes the presented session and links it to its successor; presenting an already-rotated token lands on that 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 once are covered by the same rule because the rotation is claimed atomically throughIRefreshSessionStore.TryRotateAsync; and an expired session is not a reuse signal. Both paths share one carve-out (:24-27): a token rotated less than RefreshSessionSettings.ReuseGraceSecondsago (default 10,MMCA.Common/Source/Core/MMCA.Common.Application/Auth/RefreshSessionSettings.cs:98) is a rotation race between sibling requests, answered409 Conflict(Auth.RefreshSuperseded) with nothing revoked; past the grace it is a replay. A token whose row was revoked by a sign-out or a session-cap eviction is not a theft signal either: that device simply lost its session, so only that request fails. The practical effect: a stolen-and-replayed token costs the attacker the session and costs the user only a re-sign-in, while a double-fired refresh from two tabs does not sign anyone out. - Walkthrough:
- Lifetimes (
:57-61):AccessTokenLifetimeandRefreshTokenLifetimeread the token service and fall back to 15 minutes and 7 days when the value is non-positive (a test double or a misconfigured host), so the expiry reported to clients matches the JWT's actualexp(:31-35). IssueAsync(:64): opens the session first (OpenSessionAsync,:77), saves (:83), and only then callsmintAccessToken(opened.Value!.SessionId)(:86); the comment at:75-76says why: the token carries the session's id insid, and a session has an id only once created.OpenSessionAsync(:352): generates a refresh token, creates the row throughRefreshSession.Createwithnow + RefreshTokenLifetime, runsEnforceSessionCapAsync, stages the insert without saving and returns an IssuedSession.EnforceSessionCapAsync(:512): reads the user's un-revoked rows, keeps the ones active now, orders oldest first (CreatedAt, thenId), and revokes from the front withReasonSessionCapuntil there is room for one more (loop at:521). Its summary (:503-511) records the default cap of 10, the 1-1000 startup range, and that expired-but-unrevoked rows do not count and age out throughRefreshSessionCleanupService.RotateAsync(:92):ResolveRotatableSessionAsync(:103), thenRotateSessionAsync(:111), then the mint with the successor's id (:122), so the client's current-device marker follows the rotation (comment:118-120).ResolveRotatableSessionAsync(:298): a blank token, an unknown hash, or a row of another user fails alone (:304-316). A revoked row (:318-340) now splits three ways: ifIsReuseSignalis false (signed out or cap-evicted) it fails alone (:320-326); if it was rotated inside the grace (IsWithinRotationGrace) it answersRefreshSupersededErrorwith nothing revoked (:328-333); otherwise it revokes every live session withReasonReuseDetectedand saves before failing (:335-339). An expired row fails alone (:342-344). Its summary (:284-297) explains why the unknown-hash case must not revoke the family: otherwise anyone holding one of this user's expired access tokens could sign them out everywhere by posting a random token.RotateSessionAsync(:394): builds the successor, then asks the store to claim the rotation viaTryRotateAsync(:417-419). A lost claim (:421-451) means a concurrent request spent this token first, but the tracked copy was read before that claim and still looks live, so the row is re-read untracked throughIRefreshSessionStore.FindByIdUntrackedAsync(:427-429): a sign-out or cap eviction of the same row fails alone (:430-436), a rotation inside the grace answersRefreshSupersededError(:438-441), and anything else (rotated longer ago, already flagged as reuse, or unreadable) gets the replay answer: family revoked, saved,Auth.InvalidRefreshToken(:443-450). Rotation replaces one session with one, so the cap is not re-evaluated.IsReuseSignal(:462) andIsWithinRotationGrace(:479): the first treats a revoked row as reuse when it has a successor hash or any reason other than signed-out or session-cap (the conservative answer for rows the code does not recognize); the second is true only for theRotatedreason, with a positive grace andnow - RevokedAtunder it, so a row flagged as reuse, signed out or evicted never qualifies however recent.InvalidRefreshTokenError(:267) andRefreshSupersededError(:278):Error.Unauthorized("Auth.InvalidRefreshToken", ...)is what every failing branch returns except the grace race, so a caller cannot tell unknown from expired from replayed (doc:261-266); the race answersError.Conflict("Auth.RefreshSuperseded", ...)so the client keeps its session and retries with the winner's token (:273-282).MintForSession(:132): arms the nested SessionStampingTokenService with the session id and second-factor method, runs the callback, and disarms in afinally.SignOutAsync(:150): revokes only a live session of this user found by token hash (:156-171); anything else degrades to revoking every live session rather than reporting success for a revocation that reached nothing (comment:160-163, fallback:174-175).SignOutEverywhereAsync(:179) always revokes everything.ListActiveAsync(:193): one query for un-revoked rows, then an in-memory filter to the active ones, newest first, flagging the caller's own session (:203-213).RevokeSessionAsync(:231): the ownership check is the user-scopedFindByIdAsyncquery (:236), so another account's id and a missing id produce the sameAuth.SessionNotFound(:237-244); an already-revoked row writes nothing and now answers the not-found failureAuth.SessionAlreadyRevoked(:246-253) instead of success, so the client can say the device was already signed out; the session is the caller's own, so the answer reveals nothing about another account (remarks:218-230).
- Lifetimes (
- Why it's built this way: every timestamp comes from the injected
TimeProvider, so rotation, expiry and the grace window are testable without a clock, and every user-visible failure on the refresh path collapses to one error (the single exception being the deliberate, retryable 409) so the endpoint leaks nothing about which rule fired. A lost concurrent rotation is re-read and judged by the same two predicates as a direct lookup, which keeps one rule for one observable situation. The multi-device model, rotation and reuse detection are recorded in ADR-097. - Where it's used: registered as the scoped IAuthSessionIssuer in
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:176(the comment at:174-175says the base delegates every session decision to it); consumed through that interface by AuthenticationServiceBase<TUser>. Constructed directly inAuthenticationServiceBaseTests.cs,AuthenticationServiceIdentityCompletionsTests.csandRefreshSessionManagementTests.cs(MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Auth/) and in ADC'sAuthenticationServiceTests.cs(MMCA.ADC/Tests/Modules/Identity/MMCA.ADC.Identity.Application.Tests/Users/).
RecoveryCodeSet
MMCA.Common.Application ·
MMCA.Common.Application.Auth.TwoFactor·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/TwoFactor/ITwoFactorService.cs:97· Level 0 · record
- What it is: a two-field record pairing a freshly generated set of plaintext recovery codes with their stored hashes.
- Depends on: nothing first-party.
- Concept: a plaintext/hash pair returned once, at generation time only, so the plaintext never has to be persisted or re-derived.
- Walkthrough:
Codes(ITwoFactorService.cs:98) is the plaintext shown to the user once;Hashes(line 86) is what the store keeps. - Why it's built this way: keeping the plaintext out of any store means a database compromise cannot recover usable recovery codes, only their hashes.
- Where it's used: returned by ITwoFactorService.
GenerateRecoveryCodes(), consumed byTotpTwoFactorService.csand exercised inMMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Users/TwoFactorHandlerBaseTests.cs.
TwoFactorOutcome
MMCA.Common.Application ·
MMCA.Common.Application.Auth.TwoFactor·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/TwoFactor/ITwoFactorAuthenticator.cs:47· Level 0 · enum
- What it is: the result of a second-factor challenge:
NotEnrolled = 0,VerifiedTotp = 1,VerifiedRecoveryCode = 2(ITwoFactorAuthenticator.cs:50,53,56). - Depends on: nothing first-party.
- Concept: same explicitly-numbered, wire-stable outcome-enum shape used throughout the domain event taxonomy (see
DomainEntityStatein group-02);NotEnrolled = 0is the default/no-op value, matching that convention. - Walkthrough: three values;
NotEnrolledmeans the account has no active second factor so nothing was challenged,VerifiedTotpmeans a time-based code from the authenticator app verified,VerifiedRecoveryCodemeans a single-use recovery code verified and was spent. - Why it's built this way: collapsing the challenge result to one enum lets
AuthenticationServiceBasedecide, in one switch, whether to continue sign-in unchanged (NotEnrolled) or stamp anmfaclaim on the issued token (the two verified cases). - Where it's used: returned by ITwoFactorAuthenticator.
ChallengeAsync, read byAuthenticationServiceBase.cs(7 sites) andTwoFactorAuthenticator.cs(7 sites), and by the disable/regenerate handler bases.
TwoFactorSettings
MMCA.Common.Application ·
MMCA.Common.Application.Auth.TwoFactor·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/TwoFactor/TwoFactorSettings.cs:15· Level 0 · class
- What it is: the options class bound to
Authentication:TwoFactor, controlling the TOTP parameters (issuer, digits, period, clock-skew window) and the recovery-code and secret sizing. - Depends on: nothing first-party;
System.ComponentModel.DataAnnotations([Range],[Required],[StringLength]). - Concept: the same config-bound settings shape as EmailConfirmationSettings; no new concept.
- Walkthrough:
Issuer(TwoFactorSettings.cs:26) defaults to"MMCA"and labels the account in the authenticator app.Digits(line 30) defaults to 6.PeriodSeconds(line 34) defaults to 30.VerificationWindowSteps(line 46) defaults to 1, and its remarks (lines 40-44) explain the trade-off: one step widens acceptance to roughly ninety seconds to tolerate clock skew, while a larger window multiplies how many codes are live at once for an attacker guessing.RecoveryCodeCount(line 50) defaults to 10.RecoveryCodeByteLength(line 57) defaults to 10 bytes (eighty bits).SecretByteLength(line 61) defaults to 20 bytes, the RFC 4226 recommendation. - Why it's built this way: every size and window is a documented security/usability trade-off rather than an arbitrary constant, per ADR-116.
- Where it's used: read by
TotpTwoFactorService.cs(2 sites) and registered inMMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs.
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:66), 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:95) 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:94); 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:52), itsChangePasswordHandler(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ChangePassword/ChangePasswordHandler.cs:26) 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:114callscontext.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:38andMMCA.Store/Source/Modules/Identity/MMCA.Store.Identity.Application/DependencyInjection.cs:43, both asTryAddScoped<ISoftDeletedUserValidator, SoftDeletedUserValidator<User>>().
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:162and: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:155-164). - Where it's used: injected into AuthenticationServiceBase<TUser> (
AuthenticationServiceBase.cs:77), 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:171-172(access token plus thespeaker_idclaim). The rotated pair produced here is what CookieSessionRefresher later exchanges on the browser's behalf.
ITwoFactorService
MMCA.Common.Application ·
MMCA.Common.Application.Auth.TwoFactor·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/TwoFactor/ITwoFactorService.cs:16· Level 1 · interface
- What it is: the TOTP/authenticator-app abstraction: mints secrets, builds the provisioning URI, verifies codes, and generates/hashes/matches recovery codes.
- Depends on: RecoveryCodeSet (return type of
GenerateRecoveryCodes). - Concept introduced: a service interface that isolates the cryptographic mechanics of TOTP (RFC 6238-style time-based codes) from the account-state orchestration that lives in ITwoFactorAuthenticator.
[Rubric §1, SOLID](assesses single-responsibility separation): this interface only knows about codes and secrets, never about a user account or a store. - Walkthrough:
GenerateSecret()(ITwoFactorService.cs:22) mints a fresh Base32 secret.BuildProvisioningUri(secret, accountName)(line 37) builds theotpauth://totp/...URI an authenticator app scans as a QR code; it is deliberately returned asstringrather thanSystem.Uri(the[SuppressMessage]at lines 33-36 explains that round-tripping throughUriwould re-normalize the percent-encoding the app parses).VerifyCode(secret, code)(line 46) checks a code within the configured number of time steps on each side of the current one. The overloadVerifyCode(secret, code, out matchedStep)(line 59) verifies exactly the same way and also reports which time step matched (Unix seconds divided by the period, or 0 when nothing matched), so a caller that keeps per-account state can refuse a replay of a code it already accepted inside the same window.GenerateRecoveryCodes()(line 66) returns a fresh RecoveryCodeSet.HashRecoveryCode(code)(line 74) hashes one plaintext code the way the store keeps it.TryMatchRecoveryCode(code, storedHashes, out matchedHash)(line 83) compares in fixed time to avoid a timing side-channel on which stored hash matched. - Why it's built this way: keeping code verification, secret generation, and recovery-code hashing behind one narrow interface means ITwoFactorAuthenticator and the handler bases never touch a raw cryptographic primitive directly. Replay protection stays out of this interface too: the service only reports the matched step, and the stateful "accept each step once" rule lives in the caller.
- Where it's used: implemented by TotpTwoFactorService (
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/TwoFactor/TotpTwoFactorService.cs:61for the step-reporting overload), consumed by the enrollment/regeneration handler bases inMMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/TwoFactor/and by TwoFactorAuthenticator, which calls the step-reporting overload (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/TwoFactor/TwoFactorAuthenticator.cs:65) and then rejects any step at or below the last one it cached for that user (TwoFactorAuthenticator.cs:106). Registered as a singleton inMMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Auth.cs:48.
TwoFactorErrors
MMCA.Common.Application ·
MMCA.Common.Application.Auth.TwoFactor·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/TwoFactor/TwoFactorErrors.cs:15· Level 2 · class
- What it is: the static
Error-factory class for the two-factor flow: challenge-required, challenge-invalid, not-enrolled, and enrollment-missing. - Depends on:
Error. - Concept: same convention as EmailConfirmationErrors; no new concept.
- Walkthrough: four codes (
TwoFactorErrors.cs:18,21,24,27).TwoFactorRequired(source)(lines 32-35) isError.Unauthorized, asking the caller to supply a code or a recovery code.TwoFactorInvalid(source)(lines 45-48) is alsoError.Unauthorized; its remarks (lines 40-44) note it is deliberately the same message for a wrong TOTP code and a wrong recovery code, since telling them apart would leak whether a presented value was recovery-code shaped.TwoFactorNotEnrolled(source)(lines 53-56) andTwoFactorEnrollmentMissing(source)(lines 61-64) are bothError.Conflict, for an action attempted on an account with no active factor and on an enrollment that was never started, respectively. - Why it's built this way:
ConflictversusUnauthorizedis used to distinguish "your account state does not support this action" from "your credentials did not verify", matching the semantics the rest of the auth surface uses for those two error kinds. - Where it's used: raised by
ConfirmTwoFactorEnrollmentHandlerBase.cs(2 sites),TwoFactorAuthenticator.cs(2 sites),DisableTwoFactorHandlerBase.cs, andRegenerateRecoveryCodesHandlerBase.cs; asserted inTwoFactorHandlerBaseTests.cs,AuthenticationServiceIdentityCompletionsTests.cs, andTwoFactorAuthenticatorTests.cs.
ITwoFactorAuthenticator
MMCA.Common.Application ·
MMCA.Common.Application.Auth.TwoFactor·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/TwoFactor/ITwoFactorAuthenticator.cs:24· Level 3 · interface
- What it is: the account-level orchestration for the second-factor challenge at sign-in, one level above ITwoFactorService's pure cryptographic operations.
- Depends on:
UserIdentifierType, TwoFactorOutcome, TwoFactorErrors,Result<T>. - Concept: the service-orchestrates-over-a-crypto-primitive layering also seen with IEmailConfirmationTokenService versus its store; no new concept.
- Walkthrough:
ChallengeAsync(userId, code, cancellationToken)(ITwoFactorAuthenticator.cs:40-43) returns TwoFactorOutcome.NotEnrolledwhen the account has no active second factor (sign-in continues unchanged, nomfaclaim), the verified outcome when a code satisfies the challenge, or a failure: TwoFactorErrors.TwoFactorRequiredCodewhen a code was needed and none arrived, TwoFactorErrors.TwoFactorInvalidCodewhen one arrived and did not verify (doc comment lines 32-39). - Why it's built this way: returning an outcome enum rather than a bare boolean lets
AuthenticationServiceBasedistinguish "no second factor configured" from "verified", which matters for whether it stamps anmfaclaim on the issued token. - Where it's used: implemented by
TwoFactorAuthenticator(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/TwoFactor/TwoFactorAuthenticator.cs), called fromAuthenticationServiceBase.cs(2 sites) and registered inMMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs.
ITwoFactorStore
MMCA.Common.Application ·
MMCA.Common.Application.Auth.TwoFactor·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/TwoFactor/ITwoFactorStore.cs:24· Level 3 · interface
- What it is: the persistence abstraction for one account's two-factor state: enrollment lifecycle, recovery-code replacement, and single recovery-code consumption.
- Depends on:
UserIdentifierType,ITwoFactorUserState(the read shape, taught elsewhere in this group),Result. - Concept: a two-phase enrollment (start, then complete) so an abandoned setup cannot lock an account out; the same two-phase shape a reader has already seen in other enrollment-style flows in this group.
- Walkthrough:
GetAsync(userId, cancellationToken)(ITwoFactorStore.cs:34) returnsnullonly when the account itself does not exist; an account that never enrolled still returns a state withIsTwoFactorEnabled = false.StartEnrollmentAsync(userId, secret, cancellationToken)(line 44) stores a freshly minted secret WITHOUT activating the factor, per its summary (lines 36-39), so an abandoned enrollment cannot lock the user out.CompleteEnrollmentAsync(userId, recoveryCodeHashes, cancellationToken)(lines 54-57) activates the factor and stores the first recovery-code hashes, called only after a code from the stored secret has verified.DisableAsync(userId, cancellationToken)(line 66) turns the factor off and clears the secret and recovery codes, so a later re-enrollment starts from a fresh secret.ReplaceRecoveryCodesAsync(userId, recoveryCodeHashes, cancellationToken)(lines 76-79) replaces the whole set, never appends, since regeneration exists to invalidate a list the user believes compromised.ConsumeRecoveryCodeAsync(userId, recoveryCodeHash, cancellationToken)(lines 93-96) removes one hash to spend it, and its remarks (lines 84-88) flag this as security-critical: it must persist before the sign-in it authorized is answered, or the same code could sign in twice. - Why it's built this way: splitting
StartEnrollmentAsync/CompleteEnrollmentAsyncprevents a half-finished enrollment from ever becoming an active, un-verified second factor. - Where it's used: implemented and called by
TwoFactorAuthenticator.cs(2 sites) through the enrollment/disable/regenerate handler bases inMMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/TwoFactor/, and registered inMMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs.
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. 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:307. It supplies theRolesset the CQRS authorization decorators check permissions against (MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/AuthorizationCommandDecorator.cs:32, 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:69, 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.
ITwoFactorUserState
MMCA.Common.Domain ·
MMCA.Common.Domain.Auth·MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/ITwoFactorUserState.cs:27· Level 0 · interface
- What it is: the two-factor enrollment surface an Identity module's
Useraggregate exposes: whether the second factor is active, the Base32 shared secret enrollment is derived from, and the hashes of the account's unspent recovery codes (MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/ITwoFactorUserState.cs:29-40). - Depends on: nothing first-party beyond the BCL
IReadOnlyCollection<string>(:1). - Concept introduced: two-factor state read through a capability interface, not the concrete
User.[Rubric §1, SOLID]assesses interface segregation: a shared handler that needs to know whether TOTP is enrolled never needs the rest of the app'sUsershape, so it depends on this narrow contract instead.[Rubric §11, Security]assesses the shape of the stored secret itself:TwoFactorSecret(:35) is nullable, meaning "no enrollment started" is representable without a sentinel value, andTwoFactorRecoveryCodeHashes(:42) holds hashes, not the plaintext codes a user was shown once at generation time. - Walkthrough:
bool IsTwoFactorEnabled(:29) gates whether a code is demanded at sign-in.string? TwoFactorSecret(:35) is the Base32 shared secret,nulluntil enrollment begins.IReadOnlyCollection<string> TwoFactorRecoveryCodeHashes(:42) is empty both before enrollment and after every recovery code has been spent, which the doc comment (:38-39) states is a state the user recovers from by regenerating rather than an error condition. - Why it's built this way: keeping this state on its own interface, separate from IAuthUser and IPasswordChangeableUser, lets an app opt into two-factor without every existing
Useraggregate needing new members it never populates. See ADR-116 for the opt-in model this interface belongs to. - Where it's used: the generic constraint on BeginTwoFactorEnrollmentHandlerBase<TCommand> (
MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/TwoFactor/BeginTwoFactorEnrollmentHandlerBase.cs), and it is the state ITwoFactorStore reads and writes against the concreteUser.
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:72), 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:1004). - 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.
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:49), 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:94-95). 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.
IEmailConfirmableUser
MMCA.Common.Domain ·
MMCA.Common.Domain.Auth·MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IEmailConfirmableUser.cs:22· Level 3 · interface
- What it is: the email-confirmation surface an Identity module's
Useraggregate exposes: whether the address has been proved, and a method that marks it confirmed (MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/IEmailConfirmableUser.cs:24-32). - Depends on: Result from
MMCA.Common.Shared.Abstractions. - Concept introduced: idempotent confirmation by contract.
[Rubric §4, DDD]assesses whether an aggregate enforces its own invariants rather than trusting the caller, andConfirmEmail()'s doc comment (:26-30) states the requirement directly: implementations should be idempotent, so redeeming a second token for an address that is already confirmed is an ordinary duplicate outcome, not a fault. That reading matters because a user can legitimately click an old confirmation link twice (a second tab, a forwarded email), and the workflow must not turn that into an error. - Walkthrough:
bool IsEmailConfirmed(:24) is the current state.Result ConfirmEmail()(:32) returns a success result, or the aggregate's own invariant failure, and takes no parameters: the token itself is verified upstream by IEmailConfirmationTokenService before this method is ever called. - Why it's built this way: keeping confirmation as its own capability interface, alongside ITwoFactorUserState and IUserPreferences, lets an app that does not require email confirmation skip implementing it, consistent with the opt-in model of ADR-116.
- Where it's used: the generic constraint on SendEmailConfirmationHandlerBase<TUser, TCommand> and ConfirmEmailHandlerBase<TUser, TCommand>, and by AuthenticationServiceBase<TUser>, which reads
IsEmailConfirmedon the login path.
ILegalAcceptingUser
MMCA.Common.Domain ·
MMCA.Common.Domain.Auth·MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/ILegalAcceptingUser.cs:21· Level 3 · interface
- What it is: the terms-acceptance surface an Identity module's
Useraggregate exposes: which terms version the user last accepted, when, and a method that records a new acceptance (MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/ILegalAcceptingUser.cs:21-38). - Depends on: Result from
MMCA.Common.Shared.Abstractions. Nothing else; like IUserPreferences it is not tied to IAuthUser. - Concept: idempotent acceptance by contract.
[Rubric §4, DDD]assesses whether an aggregate enforces its own invariants, and theAcceptTermsdoc comment states that implementations should be idempotent for the same version: accepting the version already on record is an ordinary repeat, not a fault (:34-37). This mirrors IEmailConfirmableUser, so a double-submitted acceptance form does not surface as an error. - Walkthrough:
string? AcceptedTermsVersion(:29) andDateTime? TermsAcceptedOn(:32, UTC) are read-only andnullwhen the user never accepted.Result AcceptTerms(string version, DateTime acceptedOn)(:42) takes a non-blank version and the UTC instant, and returns success or the aggregate's invariant failure. - Why it's built this way: a separate capability interface lets an app that does not gate on legal terms skip it, consistent with the opt-in model of ADR-116. The clock is passed in rather than read inside the aggregate, so the caller owns time.
- Where it's used: AuthenticationServiceBase<TUser> documents the
user.AcceptTerms(CurrentTermsVersion, now)call on anILegalAcceptingUser(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:115), and ILegalAcceptanceService names it as the typical aggregate shape (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Legal/ILegalAcceptanceService.cs:8). The ADC User aggregate implements it (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Domain/Users/User.cs:35).
PermissionGrant
MMCA.Common.Domain ·
MMCA.Common.Domain.Auth·MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/PermissionGrant.cs:24· Level 3 · class (sealed)
- What it is: one row granting a single
area:capabilitypermission to a single role, with an audit trail of when and by whom it was created (MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/PermissionGrant.cs:24-94). - Depends on: Result and Error, both from
MMCA.Common.Shared.Abstractions. Persisted through IPermissionGrantStore and its EF adapter, and read into IPermissionCatalog/IPermissionRegistry at runtime. - Concept introduced: two comparison conventions pinned by comment, not by type.
[Rubric §11, Security]assesses authorization correctness, and this class carries the two rules an authorization decision depends on directly on the properties:Role(:188) is compared case-insensitively everywhere it is read, matchingPermissionRegistryandRoleValue;Permission(:194) is compared ordinally, matching the compiled registry. Getting either comparison wrong would make a grant silently not match the token it was meant to authorize. - Walkthrough:
RoleMaxLength = 64(:176) andPermissionMaxLength = 128(:179) are published column widths.Id(:182) defaults to a freshGuid.RoleandPermission(:188,:194) arerequired init-only strings.GrantedAt(:197) isrequired;GrantedBy(:203) is optional and documented as informational only, never part of an authorization decision, so a row written by a migration with no principal is still honored.Create(string role, string permission, DateTime grantedAt, string? grantedBy = null)(:213-242) is the only constructor path: it fails validation withPermissionGrant.RoleInvalidwhen the role is blank or over 64 characters (:219-225), andPermissionGrant.PermissionInvalidwhen the permission is blank or over 128 (:227-233), then trims both before storing. - Why it's built this way: validating length and blankness in the factory rather than trusting the caller keeps an oversized or empty value from reaching the database as a constraint violation instead of a
[Rubric §4, DDD]-shapedResultfailure. Grouping the two comparison conventions on the type that stores the data, rather than leaving them implicit in the store or the registry, is what keeps every reader consistent. - Where it's used: written and read by EFPermissionGrantStore and StoredPermissionRoleAdministrationService, mapped by PermissionGrantModelBuilderExtensions, and its
RoleMaxLength/PermissionMaxLengthconstants are cited by SetUserRolesRequestValidator and SetRolePermissionsRequestValidator (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/AdministrationRequestValidators.cs:22-23,:41-42) when validating admin requests that set roles or permissions.
RefreshSession
MMCA.Common.Domain ·
MMCA.Common.Domain.Auth·MMCA.Common/Source/Core/MMCA.Common.Domain/Auth/RefreshSession.cs:43· Level 4 · 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:9-12). It implements IAnonymizable (:39) so the device metadata it captures has an erasure path. - Depends on: Result and Error (
:5), PiiAttribute (:3), IAnonymizable (:4), 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 four 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(:72), so a database read cannot mint tokens (:14-16). 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(:19-22,:83-87). 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 (
:24-31) 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). - Personal data is separable from the credential. The class comment (
:32-37) splits the row in two:IpAddressandUserAgentidentify the data subject's device and network, so both carry[Pii]andAnonymizeclears them for an erasure request (ADR-005), while the token hashes, timestamps and revocation chain carry no personal data and are kept. That split is what lets reuse detection keep working on an anonymized row. It also satisfies the framework's PII convention, under which any domain entity with a[Pii]-marked property must implementIAnonymizable(MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Rules/Governance/ArchitectureRules.Governance.cs:10).
- 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:45-67):TokenHashLength = 64(the width of a hex-encoded SHA-256 digest,:42),IpAddressMaxLength = 45(sized to fit an IPv4-mapped IPv6 literal,:45),UserAgentMaxLength = 512(:48),ReasonRevokedMaxLength = 64(:51), and the four revocation reasonsReasonRotated(:54),ReasonSignedOut(:57),ReasonReuseDetected(:60), andReasonSessionCap(:63, value"SessionCapExceeded"). 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 (
:65-105):Iddefaults to a freshGuid(:66);UserId,TokenHash,CreatedAtandExpiresAtarerequiredandinit-only (:69-78), so a session cannot be constructed without them and cannot be rewritten afterwards. Three revocation members carryprivate setand change only throughRevoke:RevokedAt(:81),ReplacedByTokenHash(:87),ReasonRevoked(:90).IpAddress(:98) andUserAgent(:105) are optional capture, each marked[Pii](:97,:104) and alsoprivate set: they are written once byCreateand afterwards only cleared byAnonymize, never set from outside. TheUserAgentcomment (:100-103) gives the reason for its marking: alongside the IP it fingerprints the data subject's device. The comment onIpAddress(:92-96) 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(:108) andIsActiveAt(DateTime utcNow) => !IsRevoked && ExpiresAt > utcNow(:112). 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(...)(:125-158), 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(:133-139), and an expiry at or before creation fails withRefreshSession.ExpiryInPast(:141-147), bothError.Validation. On success it hashes the token on the way in (:152), so the plaintext never reaches a property, and truncates the two optional capture fields to their column widths (:155-156). 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)(:173-177):Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(refreshToken))), guarded byArgumentException.ThrowIfNullOrWhiteSpace(:175). The<remarks>(:164-170) 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)(:187-202): the only mutator of session state. It is idempotent by refusal (:180-181), returningError.Invariant("RefreshSession.AlreadyRevoked", ...)when the session is already revoked (:189-195) 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 (:197-199).Anonymize()(:210-215): the IAnonymizable member. It nullsIpAddressandUserAgentand returnsResult.Success(). UnlikeRevokeit is idempotent by acceptance (:204-209): an already-anonymized session stays as it is and the call still succeeds, because erasing absent data is not an error. It deliberately leaves the session's validity untouched; revoking is the sign-out path's job, so anonymizing a row and ending it remain two separate decisions.Truncate(:217-218) 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/Revoke/Anonymizeas the only ways in and out means every row in the table was validated, every revoked row carries a reason, and personal data can be erased without breaking the chain. - Where it's used: AuthSessionIssuer is the main consumer. It hashes a presented token to look the session up (
MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Sessions/AuthSessionIssuer.cs:157,:285), creates one per login (:320) and per rotation (:360), revokes on sign-out (:166,:172,:180) and on a by-id revoke from the device list (:247), revokes the live family on reuse detection (:295,:383), and evicts the oldest session withReasonSessionCapwhen a user is atRefreshSessions:MaxActiveSessionsPerUser(:427, documented at:409; default 10 atMMCA.Common/Source/Core/MMCA.Common.Application/Auth/RefreshSessionSettings.cs:35). ItsListActiveAsyncfilters withIsActiveAtand projectsIpAddressandUserAgentinto RefreshSessionSummaryResponse for the "your devices" list (AuthSessionIssuer.cs:204-213). RefreshSessionRevocation also revokes withReasonSignedOut(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/RefreshSessionRevocation.cs:47). No code underMMCA.Common/SourcecallsRefreshSession.Anonymizetoday: the method is the erasure path the PII convention requires, available to an application's erasure flow. 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:125; the EF implementation does it as a conditionalExecuteUpdate,MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Auth/EFRefreshSessionStore.cs:142,:142). The table is mapped throughApplyRefreshSessionConfiguration(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs:900), and the account-deletion path deliberately does not revoke sessions (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/DeleteUser/DeleteUserHandlerBase.cs:107-111): 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:118, 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:45-46).[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:66), 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:485-494), 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.
EmailConfirmationEntry
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Auth·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/EmailConfirmationTokenService.cs:174· Level 0 · record (internal sealed)
- What it is: the cached confirmation record behind the email-confirmation flow: what EmailConfirmationTokenService writes into the cache when a confirmation 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/EmailConfirmationTokenService.cs:174-178). - Depends on: nothing first-party except the
UserIdentifierTypealias (the per-moduleglobal usingidentifier alias, ADR-048). Four positional parameters, all BCL primitives. - Concept: the same cache-DTO rule PasswordResetEntry demonstrates: a value round-tripped through
System.Text.Jsonin a distributed cache must be a JSON primitive, so the token digest travels as Base64 text (TokenHashBase64,:170) rather than asbyte[], and the expiry as Unix seconds (ExpiresAtUnixSeconds,:173) rather than asDateTimeOffset.[Rubric §11, Security]applies through the same member name convention:TokenHashBase64, neverToken, so the record cannot hold the redeemable secret it guards. - Walkthrough: four members, the same shape as PasswordResetEntry:
string TokenHashBase64(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/EmailConfirmationTokenService.cs:175): the SHA-256 digest of the issued token, Base64-encoded. Validation re-hashes the presented token and compares digests.UserIdentifierType UserId(:171): the account the token confirms.int FailedAttempts(:172): wrong tokens presented against this record so far.long ExpiresAtUnixSeconds(:173): when the record expires; a failed-attempt rewrite recaches 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/EmailConfirmationTokenService.cs:138-151).
- Why it's built this way: being a
recordgives thewithexpression the attempt-counter rewrite relies on (entry with { FailedAttempts = attempts },MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/EmailConfirmationTokenService.cs:155). Beinginternalkeeps the cache layout out of the package's public API. - Where it's used: written by
IssueAsync(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/EmailConfirmationTokenService.cs:69-75), read byValidateAndConsumeAsync(:85), and rewritten byRecordFailedAttemptAsync(:148-151). It appears nowhere else.
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:197) 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:65-70,: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:193-196), 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:90).
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:87-97), 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.Authentication.cs:217-240) 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 (:211-215).
- 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.Authentication.cs:215).
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:154-157), 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:154-157) 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:199· 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:199-203). - 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:190-194): 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:200): 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:127,: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:178), 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:84-90), read byValidateAndConsumeAsync(:100), and rewritten byRecordFailedAttemptAsync(:148-152). It appears nowhere else.
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.
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.Authentication.cs:135-138), 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.Authentication.cs:219-223).
- 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.Authentication.cs:135-138), which then re-reads the section eagerly to build the token validation parameters at wiring time (:154-157); 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:71-98).
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:197) 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/Auth/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 type is where the framework makes its whole stance on it. Four 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.FixedTimeEquals(:64) 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. AndVerifyPasswordrejects anyhash/saltpair that is not exactlyHashSize/SaltSizebytes before comparing anything (:55-58): the inline comment records that the check closes a real hole, an account created through external OAuth (ADR-036) carries an empty hash and salt, and the previous code derived an empty hash from an empty salt and compared two empty spans, whichFixedTimeEqualsanswerstruefor: any password verified successfully against such a row.[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 for null/empty/whitespace (:45-47), then rejects anyhash/saltpair whose length is not exactlyHashSize/SaltSizebytes, returningfalseimmediately (:55-58). Only canonical PBKDF2-shaped material reaches the comparison; a row that is not something this hasher ever produced verifies nothing. It then recomputes the derivation over the stored salt at the fixedHashSize(:60), and returnsCryptographicOperations.FixedTimeEquals(computedHash, hash)(:64).ComputePbkdf2Hash(string password, byte[] salt, int outputLength)(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordHasher.cs:68-74) 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:315); 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:66), 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:44,:55,:61); andResetPasswordHandlerBase<TUser, TCommand>only hashes (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/ResetPassword/ResetPasswordHandlerBase.cs:43,: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:27· Level 2 · class (sealed partial)
- 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:87-97). The class ispartialonly so the source-generated[LoggerMessage]method at its foot can be emitted (:297-298). - Depends on:
ITokenService(the port it implements) andIDisposable(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/TokenService.cs:27);JwtSettings,JwtSigningAlgorithmandJwksSettings, all bound throughIOptions<T>(:71-76);IPermissionRegistry, a required constructor dependency (:30,:73) that supplies the role-to-permission map baked into every access token; and anILogger<TokenService>(:32,:75) used for exactly one log line. Externals:System.IdentityModel.Tokens.Jwt(JwtSecurityToken,JwtSecurityTokenHandler),Microsoft.IdentityModel.Tokens(SigningCredentials,SecurityKey,TokenValidationParameters),System.Security.Cryptography(RSA,RandomNumberGenerator),Microsoft.Extensions.Logging, 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:182) under a narrowly scoped#pragma warning disable CA5404(:181, restored at:183) 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 (:178-180). Second, the algorithm is pinned twice:ValidAlgorithmslimits validation to the one algorithm this instance was built for (:187), and the token header'salgis re-compared with an ordinal string comparison after validation succeeds (:196-200). 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 (:93-97).[Rubric §14, Testability]shows up in the constructor: the clock is an injected, requiredTimeProviderwith noTimeProvider.Systemfallback (:74,:84), resolved from whatever DI registration adds it in the container'sAddServicescall (the parameter doc says so at:55-58), so a test passes its own and can assertiat/nbf/expwithout waiting for wall-clock time to pass. A third,[Rubric §20, Policy and Enforcement Points]concept sits inGenerateAccessTokenitself: every access token carries oneAuthClaimTypes.Permissionclaim per permissionIPermissionRegistrygrants the token's role (:129-141), so a client can gate its own surface on a capability rather than a role name it would otherwise have to know. A host that declared no grants resolvesUnconfiguredPermissionRegistry, which grants nothing, and its tokens simply carry no permission claims (:48-54, the constructor's own doc comment on the parameter). A fourth,[Rubric §13, Observability], is the split catch inGetPrincipalFromExpiredToken(:204-216): ordinary rejections stay silent, but anything else is logged before it is refused, so a fault in the validation path cannot hide behind an ordinary 401. - Walkthrough
- Fields (
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/TokenService.cs:29-40): the settings snapshot, the injectedIPermissionRegistry(:30), theTimeProvider(:31), theILogger<TokenService>(:32), theSigningCredentialsused to sign, theSecurityKeyand algorithm string used to validate, and two nullable ownedRSAhandles (:39-40). The comment at:37-38explains 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:71-98) takesIPermissionRegistry permissionRegistryandILogger<TokenService> loggeras required parameters, null-guarded alongsidejwtOptions(:78-80) and assigned to_permissionRegistryand_logger(:83,:85).TimeProvider timeProvideris likewise a required parameter with no default (:74), assigned straight to_timeProviderwith no fallback (:84); it is not null-guarded. The logger's parameter doc states its whole job (:59-64): record the one validation failure that is not an ordinary rejection. The constructor materializes all key material exactly once, so no token operation re-parses a PEM. ForJwtSigningAlgorithm.RS256it builds RSA credentials and pinsSecurityAlgorithms.RsaSha256(:87-92); the key id it passes isjwksSettings?.Value.KeyIdfalling back to a defaultJwksSettingsinstance (:90), 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(:95-96). GenerateAccessToken(userId, email, role, fullName, additionalClaims)(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/TokenService.cs:101-152) reads the clock once (:108), then builds six claims (:114-122):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:110-113is a 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 (:124-127). Then, before the token is assembled, permission claims are added (:129-141):alreadyClaimedcollects anyPermissionclaim value already present inclaims(:133-136), and every permission_permissionRegistry.GetPermissions(role)returns that is not already in that set is appended, ordered ordinally for a deterministic token per role (:138-141). AJwtSecurityTokenis then assembled with issuer, audience,notBeforefrom the injected clock andexpiresatnow + AccessTokenExpirationMinutes(:143-149), and serialized (:151).GenerateRefreshToken()(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/TokenService.cs:155-159) 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:162,:165) 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:174-217) builds the validation parameters described above (:176-188), validates (:194), applies the post-validationalgre-check and returnsnullwhen the token is not aJwtSecurityTokenor the header disagrees (:196-200). The failure path is two catches. ASecurityTokenExceptionorArgumentException(bad signature, wrong issuer or audience, malformed input) returnsnullwithout a log line, because a client presenting a bad token is not an event (:204-209). Any other exception is treated as a fault in the validation path: it is logged at warning level through the source-generatedLogUnexpectedValidationFailure(:214, declared at:297-298) and still answered withnull(:210-216), so the method fails closed either way. 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:220-224) releases both ownedRSAhandles.BuildHmacCredentials(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/TokenService.cs:226-238) throwsInvalidOperationExceptionwhenSecretForKeyis missing (:228-232) and Base64-decodes it into aSymmetricSecurityKeyused for both signing and validation (:235-237).BuildRsaCredentials(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/TokenService.cs:240-295) throws whenRsaPrivateKeyPemis missing (:244-248), imports the private key (:250-253), and stamps the key id on the signing key (:259) so every RS256 token carries akidheader. The comment at:255-258gives 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 (:265-276), so an issuer configured with only a private key still validates its own tokens during refresh; it carries the same key id (:281). Both nestedtry/catchblocks dispose the partially builtRSAbefore rethrowing (:284-288,:290-294), 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:308-314): 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 service is stateless after construction; the comment names only theIOptions<JwtSettings>dependency, and the logger, clock and permission registry it also takes are resolved once at that same construction. Baking permission claims into the token itself, rather than having a client call back to look them up, is the same shape ADR-020 establishes for permission-based authorization: the permission set travels with the credential. - Where it's used: registered as
services.TryAddSingleton<ITokenService, TokenService>()(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:314), which resolvesIPermissionRegistryand the logger from the container alongside the JWT options. Consumed through the port in two places.AuthSessionIssuermints the tokens: itsAccessTokenLifetimereads this service's lifetime with a 15-minute floor when the setting is non-positive (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Sessions/AuthSessionIssuer.cs:57-58), and refresh tokens are minted at:319and:359. The same file wraps this service in the privateSessionStampingTokenServicepass-through (:448), which appends theAuthClaimTypes.SessionId(sid) claim through theadditionalClaimsparameter (:484), so per-device session identity is layered on without this type knowing about sessions at all.AuthenticationServiceBase<TUser>reads the expired token in the refresh flow (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:323). 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:71-98) 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.
EmailIdentity
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Auth·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/EmailIdentity.cs:12· Level 5 · class (internal static)
- What it is: the one shared address-normalization helper for every cache key this module builds from an email address.
internal static string Normalize(string? email)runs the supplied address through Email.Createand returns the normalized value; a malformed address (which never matches a user but can still mint a key) falls back to the same trim-and-lowercase shape so its attempts collapse onto one key too (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/EmailIdentity.cs:14-21,:22-31). - Depends on: the Email value object (
:41) only. - Concept introduced: one normalizer, extracted out from under three copies.
[Rubric §15, Best Practices & Code Quality]assesses whether a proven idiom is reused or reinvented per call site. LoginProtectionService, PasswordResetTokenService and EmailConfirmationTokenService each used to carry their own privateNormalizeIdentitymethod, identical byte for byte, with each one's doc comment pointing at another as "the reason".EmailIdentity.Normalizeis that method pulled out to one place: the three services now call it instead of copying it, so the normalization rule (and the scoped#pragma warning disable CA1308that documents why lowercasing is safe here) exists in exactly one file. - Walkthrough: a single method.
Normalize(string? email)(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/EmailIdentity.cs:22-31) returnsstring.Emptyfor a null, empty or whitespace-only input (:24-25); otherwise it callsEmail.Create(email)and returns the normalizedValueon success, oremail.Trim().ToLowerInvariant()on failure (:27-29), under#pragma warning disable CA1308(:28-30) because that fallback intentionally matchesEmail's own RFC 5321 lowercase normalization. - Why it's built this way:
internal statickeeps the helper out of the package's public API, since it exists purely to keep the three token/lockout services' cache keys consistent with each other and with the Email value object's own normalization, not to be a general-purpose utility. - Where it's used:
LoginProtectionKey-style builders in LoginProtectionService (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs, 2 call sites), PasswordResetTokenService (2 call sites) and EmailConfirmationTokenService (2 call sites); unit-tested directly byEmailIdentityTests(MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Auth/EmailIdentityTests.cs).
EmailConfirmationTokenService
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Auth·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/EmailConfirmationTokenService.cs:36· Level 6 · class (sealed)
- What it is: the IEmailConfirmationTokenService implementation: the email-confirmation token lifecycle, 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/EmailConfirmationTokenService.cs:12-25). The class remarks add why its keys carry their ownemailconfirm:prefix: a key shared with the reset service would make issuing a confirmation link silently invalidate an outstanding password-reset link for the same address (:27-32). - Depends on:
IEmailConfirmationTokenService(the Application port it implements,MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/EmailConfirmationTokenService.cs:39); EmailConfirmationSettings viaIOptions<>(:38, snapshotted at:43); ICacheService (:37);TimeProvider(:39), the clock for the expiry stamp and the remaining-lifetime check (:35); EmailConfirmationEntry, its cached record; EmailConfirmationErrors for the failure; Result and Error; EmailIdentity, the shared normalizer, at the two key builders (:130,:132); and from the BCLSHA256,RandomNumberGenerator,CryptographicOperations, andSystem.Buffers.Text.Base64Url. - Concept introduced: the same cache-backed token pattern, applied a third time.
[Rubric §11, Security]assesses credential issuance and redemption;[Rubric §15, Best Practices & Code Quality]assesses whether a proven idiom is reused or reinvented per flow. This type is structurally the same shape as PasswordResetTokenService: hash the token at rest, throttle issuance per address, cap validation attempts, and consume both the token and the request counter together on success. TheTokenKeydoc comment says so directly, citing PasswordResetTokenService as the precedent for normalizing the cache key the same way Email normalizes the lookup value (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/EmailConfirmationTokenService.cs:122-129), and both key builders call EmailIdentity.Normalizeinstead of a private copy of the same helper (:130,:132). The same read-modify-write gap LoginProtectionService documents on its own throttle is inherited here too: the comment at the top ofIssueAsyncnames it explicitly (:51-52). One place it departs from its sibling: redemption reads throughGetFromSharedStoreAsyncbut takes no distributed lock, so the single-redemption guarantee PasswordResetTokenService adds under IDistributedLock is not present here (see Caveats). - Walkthrough
TokenByteLength = 32(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/EmailConfirmationTokenService.cs:41) is the only constant;_settingsis the snapshottedEmailConfirmationSettings(:43). The primary constructor takesICacheService,IOptions<EmailConfirmationSettings>andTimeProvider(:36-39).IssueAsync(string email, UserIdentifierType userId, CancellationToken)(:46-78): increments the per-email request counter with the configuredRequestWindowMinutesTTL (:53-56) and fails withAuthentication.ConfirmationThrottledonce the count exceedsMaxRequestsPerEmail(:58-64). It then mints 32 CSPRNG bytes rendered withBase64Url.EncodeToString(:66), builds an EmailConfirmationEntry holding the Base64 digest of the token, the user id, a zero attempt count, and the absolute expiry as Unix seconds computed from the injected clock (:69-73), caches it under the token key with the configured lifetime (:75), and returns the raw token to the caller (:77); the raw token is never written anywhere else.ValidateAndConsumeAsync(string email, string token, CancellationToken)(:81-120): loads the entry withGetFromSharedStoreAsync(:90), whose comment gives the reason (:88-89): a token consumed on another replica must be a miss here, never a stale local copy that would let it be redeemed twice. It returnsInvalidToken()when there is none (:91-94); aFormatExceptiondecoding the stored Base64 removes the unreadable record rather than leaving it to expire (:96-106); the comparison isCryptographicOperations.FixedTimeEqualsover the two digests (:108), withtoken ?? string.Emptyso a null token hashes rather than throwing; a mismatch records a failed attempt and returns the same generic failure (:108-112). On a match it removes both the token key and the address's request counter (:114-117), so a confirmed address is not left throttled out of a later legitimate request, and returns the entry'sUserId(:119).TokenKey,RequestKey,HashToken(:122-135):TokenKeyandRequestKeyeach call EmailIdentity.Normalize(:130,:132), producingemailconfirm:token:{normalized}andemailconfirm:req:{normalized}keys;HashTokenis the shared SHA-256 helper (:134-135).RecordFailedAttemptAsync(:137-158): computesattempts = entry.FailedAttempts + 1and the remaining lifetime fromExpiresAtUnixSecondsagainst the injected clock (:142-143). AtMaxValidationAttempts, or once the remaining lifetime is non-positive, it deletes the record (:145-149); otherwise it rewrites the entry withentry with { FailedAttempts = attempts }at the remaining TTL, not a fresh one (:151-157).InvalidToken()(:160-162) is the single failure factory built fromEmailConfirmationErrors.InvalidToken, so unknown, expired, mismatched and attempt-capped tokens collapse to one error rather than giving an oracle for which addresses have an outstanding confirmation.
- Why it's built this way: reusing the pattern PasswordResetTokenService established, rather than a bespoke implementation, is what keeps a third token lifecycle (login lockout, password reset, email confirmation) auditable as one idiom instead of three. ADR-116 frames identity-completion flows like this one as opt-in per host, and ADR-077 is the cache substrate whose shared-store read the redemption relies on. Taking
TimeProviderrather than readingDateTimeOffset.UtcNowlets a test move the clock past the expiry without waiting. - Where it's used: registered
services.TryAddScoped<IEmailConfirmationTokenService, EmailConfirmationTokenService>()(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Auth.cs:76), directly after theEmailConfirmationSettingsbinding (:69-70). - Caveats / not-in-source: the per-email request throttle is a read-modify-write on the distributed cache, the same gap LoginProtectionService documents on its own counters: concurrent requests can undercount, which loosens the throttle but never tightens it. The failed-attempt rewrite has the same property against the attempt cap. Redemption is not serialized: the shared-store read rules out a stale replica-local copy, but nothing in this file stops two concurrent redemptions that both read before either removes from both succeeding, which is the window PasswordResetTokenService closes with a lock.
LoginProtectionService
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Auth·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:28· Level 6 · class (sealed, partial)
- 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:11-27). It fails open on a cache outage: when the cache throws, a check answers success and an increment or reset does nothing, each with a warning log, so an unreachable cache suspends the limits instead of failing every sign-in and registration (:20-26). - Depends on: ILoginProtectionService (the Application port); LoginProtectionSettings via
IOptions<>(:30, snapshotted at:32); ICacheService (:29); an optionalILogger<LoginProtectionService>defaulting toNullLogger(:31,:33); Result and Error (:6); EmailIdentity, the shared normalizer, at the two key builders (:45,:47). - 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:45-47, documented at:36-44):LockoutKeyandAttemptsKeyeach call EmailIdentity.Normalize(email)(:45,:47) rather than carrying a private copy of the normalizer. Without normalization, the counter keys would be 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 so its attempts collapse onto one key too. EmailIdentity is where that trim-and-lowercase fallback and its scoped#pragma warning disable CA1308now live; PasswordResetTokenService and EmailConfirmationTokenService call the same helper rather than each keeping their own copy. - The lockout curve (
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:94-104):excessAttempts = newCount - MaxFailedAttempts(:96) driveslockoutSeconds = Math.Min(1 << Math.Min(excessAttempts, 30), MaxLockoutSeconds)(:102), 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 (:98-101): 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}(:181, 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-73): reads the boolean lockout key (:52) throughGetFromSharedStoreAsync(:59), never the replica's L1 copy, because a reset clears only the resetting replica's L1 and a local read would keep another replica locking the account out after the lockout was lifted (:56-58; port atMMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/ICacheService.cs:65). It returnsError.TooManyRequests("Auth.TooManyAttempts", ...)when set (:68), otherwiseResult.Success(). A cache miss is treated as not locked out (?? false), and a cache outage is caught by theIsCacheOutagefilter (:61), logged, and answered withResult.Success()(:63-64), so the check fails open rather than locking everyone out.IncrementFailedAttemptsAsync(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:76-110): inside a try block (:87), increments the attempts key with theFailedAttemptWindowMinutesTTL (:89-91), and once the count reachesMaxFailedAttemptswrites the lockout key with the exponential TTL (:94-103). A cache outage is logged and swallowed (:106-109).ResetFailedAttemptsAsync(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:113-124): removes both keys on a successful login (:117-118), with the same log-and-swallow handling of a cache outage (:120-123).CheckRegistrationRateLimitAsync(string? ipAddress, ...)(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:127-155): a null or empty IP is unrestricted (:129-132); otherwise it reads the per-IP counter from the shared store, never a process-local copy, becauseIncrementAsyncwrites the shared store only and a hybrid cache's in-process entry would pin the first count it saw so the limit never trips (:138-141). It compares the count againstMaxRegistrationsPerIpPerHourand fails withAuth.RegistrationRateLimitExceeded(:149-153). Unlike the lockout check, this failure is stillError.Unauthorized(:150), notTooManyRequests. A cache outage returnsResult.Success()(:143-147).- Outage handling helpers:
IsCacheOutage(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:183-188) treats every cache fault as an outage except the caller's own cancellation, which keeps propagating; a cancellation the caller did not request (a store-side timeout) counts as an outage.LogCacheUnavailable(:191-192) is a source-generated[LoggerMessage]warning that logs the operation name, never the key, because the keys carry the email address or client IP (:190). IncrementRegistrationCountAsync(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/LoginProtectionService.cs:158-179): no-ops on a missing IP (:160-163) and otherwise increments the per-IP counter with theRegistrationRateLimitWindowMinutesTTL (:170-173), logging and swallowing a cache outage (:175-178). The comment (:165-167) 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:158). - Where it's used: injected into AuthenticationServiceBase<TUser> (
MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:67), which calls all five members across its login and registration flows: the lockout check (:136), an increment on both the unknown-user and wrong-password branches (:156,:163), the reset on success (:207), and the registration rate-limit check and increment (:242,:301); a further increment sits at:557. 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:78-86). 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:33· Level 6 · 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 exactly once on a successful redeem (
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:12-28). - Depends on: IPasswordResetTokenService (the Application port,
:5,:37); PasswordResetSettings viaIOptions<>(:35, snapshotted at:45); ICacheService (:6,:34); IDistributedLock (:36), which serializes redemptions of one token (:31);TimeProvider(:37), the clock for the expiry stamp and the remaining-lifetime check (:32); PasswordResetEntry (its cached record); Result and Error (:7); EmailIdentity, the shared normalizer, at the two key builders (:53,:55); 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. Five properties are designed in, and the class doc lists all five up front (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:15-27).- Hashed at rest. Only
SHA256.HashData(...)of the token is stored (:57-58,:85), 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,:39,:81) 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 (
:53), soSetAsyncoverwrites (:90) 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(:168-172), which turns the token into a credential you cannot grind at. - Single redemption. The read, compare and remove run as one critical section under an IDistributedLock on the token key (
:24-26,:103-117), so two concurrent redemptions of one token cannot both succeed, and a redemption that cannot take the lock is answered as invalid rather than as a second success. The lookup itself reads the shared store, so a token consumed on another replica is a miss rather than a stale local copy (:126-128). - 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 cacheService,IOptions<PasswordResetSettings> settings,IDistributedLock distributedLockandTimeProvider timeProvider(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:33-37), snapshottingsettings.Valueinto_settings(:45).TokenByteLength = 32(:39) is the one constant; two static timeouts govern the redeem lock,RedeemLockTimeToLiveof 10 seconds (:41) andRedeemLockWaitof 5 seconds (:43). TokenKeyandRequestKey(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:53-55) each call EmailIdentity.Normalize(email)rather than carrying a private normalizer, and the doc comment cites LoginProtectionService as the origin of the rule (:47-52): keys built from raw request input would giveUser@x.comanduser@x.comindependent tokens and independent request counters while resolving to one account.TokenKeyproducespwdreset:token:{normalized}(:53) andRequestKeyproducespwdreset:req:{normalized}(:55).HashTokenis the shared SHA-256 helper (:57-58).IssueAsync(string email, UserIdentifierType userId, CancellationToken)(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:61-93): throttle first. It increments the per-email request counter with theRequestWindowMinutesTTL (:68-71) and fails withError.Unauthorized("Auth.ResetThrottled", ...)once the count exceedsMaxRequestsPerEmail(:73-79). Only then does it mint the token: 32 CSPRNG bytes rendered withBase64Url.EncodeToString(:81, 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 computed from the injected clock (:84-88), caches it under the token key with the configured lifetime (:90), and returns the raw token to the caller to email (:92). 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:96-118): builds the token key (:101), then tries to acquire the distributed locklock:{key}with the 10-second TTL and a 5-second wait (:106-108). A null handle (contended or unavailable) returnsInvalidToken()(:109-112); otherwise it runsRedeemAsyncinside anawait usingover the handle, so the lock is released however the redemption ends (:114-117). The comment at:103-105states the reason: without the critical section, two callers that both read before either removes would both succeed.RedeemAsync(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:120-158): loads the entry withGetFromSharedStoreAsync(:128) and returnsInvalidToken()when there is none (:129-132). AFormatExceptiondecoding the stored Base64 removes the unreadable record rather than leaving it to expire (:134-144). The comparison isCryptographicOperations.FixedTimeEqualsover the two digests (:146), 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 (:146-150). On a match it removes both the token key and the address's request counter (:154-155), so a successful reset does not leave the user throttled out of a later legitimate request (:152-153), and returns the entry'sUserId(:157).RecordFailedAttemptAsync(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/PasswordResetTokenService.cs:160-181): computesattempts = entry.FailedAttempts + 1and the remaining lifetime fromExpiresAtUnixSecondsagainst the injected clock (:165-166). AtMaxValidationAttempts, or once the remaining lifetime is non-positive, it deletes the record (:168-172). Otherwise it rewrites the entry withentry with { FailedAttempts = attempts }and a TTL of the remaining seconds, not a fresh lifetime (:174-180), 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:183-187) is the single failure factory: unknown, expired, mismatched, attempt-capped and lock-contended 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. The redeem lock is the ADR-108 primitive, and the shared-store read leans on the ADR-077 cache substrate. Taking
TimeProviderrather than readingDateTimeOffset.UtcNowlets a test move the clock past the expiry without waiting. - Where it's used: registered
services.TryAddScoped<IPasswordResetTokenService, PasswordResetTokenService>()(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs:164), directly after thePasswordResetSettingsbinding (:160-161). 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:76-77) 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 (:74-75). 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:66-67): concurrent requests can undercount, which loosens the throttle but never tightens it. The redeem lock serializes redemptions, so the failed-attempt rewrite (also a read-modify-write) no longer races another redemption of the same token on a host whose lock is shared; the lock's own cross-replica strength depends on which IDistributedLock the host registers, which is not determinable from this file.
AdministrationPermissions
MMCA.Common.Shared ·
MMCA.Common.Shared.Auth.Permissions·MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Permissions/AdministrationPermissions.cs:15· Level 0 · class (static)
- What it is: the two permission-string constants the framework's own administration
controllers gate on,
ManageUsers(value"users:manage") andManageRoles(value"roles:manage") (MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Permissions/AdministrationPermissions.cs:15-22). - Depends on: nothing first-party; it is a plain
const stringholder consumed by the permission-authorization pipeline. - Concept introduced, the framework shipping its own permission vocabulary for the
administration surface it also ships.
[Rubric §11, Security]assesses the authorization model for built-in capabilities.UsersAdminControllerBaseandRolesAdminControllerBaseare framework code, so they need permission names that exist before any consuming app declares its own;ManageUsersandManageRolesare those names, defined once so the base controllers and every host that wires a role to them agree on the string. - Walkthrough: two
public const stringfields, each documented with the operation it gates:ManageUsers(AdministrationPermissions.cs:17-18) for listing, inspecting, locking, unlocking, and re-roling accounts;ManageRoles(AdministrationPermissions.cs:20-21) for listing roles and editing their stored permission grants. - Why it's built this way: part of the opt-in identity completions
(ADR-116):
UsersAdminControllerBase<TUserDto>andRolesAdminControllerBaseship gated on these two constants so a host adopting the built-in administration screens grants a roleAdministrationPermissions.ManageUsers/ManageRolesrather than inventing its own strings. - Where it's used:
RolesAdminControllerBaseandUsersAdminControllerBaserequire[HasPermission(AdministrationPermissions.ManageRoles)]/[HasPermission(AdministrationPermissions.ManageUsers)](MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Administration/RolesAdminControllerBase.cs,MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Administration/UsersAdminControllerBase.cs);StoredPermissionRoleAdministrationServicegrants and reads them against the layered registry (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/Administration/StoredPermissionRoleAdministrationService.cs);RoleAdminEdit.razor.cschecks them client-side before rendering the edit UI (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Administration/RoleAdminEdit.razor.cs); ADC'sIdentityPermissionsre-exports both names alongside its own conference permissions (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Authorization/IdentityPermissions.cs), andIdentityUIModulegrantsManageUsers/ManageRolesto the Organizer role (MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.UI/IdentityUIModule.cs).
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 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:31). - 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:24, value"permission") carries a single granted capability, honored in addition to the permissions a role confers through IPermissionRegistry (AuthClaimTypes.cs:11-14). The doc comment now states that TokenService itself is the emitter (AuthClaimTypes.cs:15-21): every access token it mints carries onePermissionclaim per permission the role's registry entry grants, sorted ordinally so the same role always produces the same token shape. That is what lets a client decide what to show (a navigation entry gating on a required permission) without knowing any role name, while server-side checks still go through the registry, so a host that mints tokens some other way loses nothing. This makes the model in ADR-020 backward compatible with plain role checks: a token still authorizes correctly if a reader ignores the claim entirely.Subject(AuthClaimTypes.cs:34, 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:26-33): 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:47, 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:36-40). It is additive, never required (AuthClaimTypes.cs:41-45): 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.MultiFactor(AuthClaimTypes.cs:60, value"mfa") is an RFC 8176 (amr)-style claim asserting that the access token was minted after a second authentication factor was presented (AuthClaimTypes.cs:49-59). Presence is the assertion, not the value: readers ask whether the claim exists (ClaimsPrincipalExtensions.HasMultiFactor), the value exists for audit and for UI copy such as "you signed in with a recovery code". A token issued for an account with no second factor carries nomfaclaim at all, which is why a request markedIRequiresMfadenies such a caller rather than degrading to a role check.MultiFactorMethodTotp(AuthClaimTypes.cs:63, value"otp") andMultiFactorMethodRecoveryCode(AuthClaimTypes.cs:66, value"recovery") are the only two valuesMultiFactorcarries: which second-factor method satisfied the challenge.
- Walkthrough: six
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,
ADR-097 adds the
per-device session identity that
sidnames, and the opt-in two-factor completion (ADR-116) addsmfaand its two method values. Keeping the claim types as shared constants means the writer and the reader cannot drift apart on a string. - Where it's used:
Permissionis written by TokenService itself, one claim per permission the injectedIPermissionRegistrygrants the token's role, sorted ordinally and deduplicated against any permission claim the caller already supplied (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/TokenService.cs:129-141), and is 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 by ClaimsPrincipalExtensions.HasPermissionClaim.Subjectis written by TokenService asJwtRegisteredClaimNames.Sub(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/TokenService.cs:116, 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 AuthSessionIssuer (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Sessions/AuthSessionIssuer.cs:544) and read back byFindSessionIdfor the "my sessions" endpoint (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:195).MultiFactorand its two method values are stamped by the sign-in flow after a second factor verifies, and read through ClaimsPrincipalExtensions.HasMultiFactor/FindMultiFactorMethodby any use case markedIRequiresMfa.
- Caveats / not-in-source: the
Permissionclaim is now written by every token the framework mints (TokenService.cs:129-141), which supersedes the older behavior where no shipped token issuer wrote it; a reader depending onPermissionbeing absent from real tokens would be wrong today. The registry lookup remains the authoritative check either way, since the claim path is additive.
AuthErrorCodes
MMCA.Common.Shared ·
MMCA.Common.Shared.Auth·MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/AuthErrorCodes.cs:12· Level 0 · class (static)
- What it is: two registration error-code constants.
EmailAlreadyExists(value"Auth.EmailAlreadyExists") is returned when registration is refused because the address already belongs to an account;TermsNotAccepted(value"Auth.TermsNotAccepted") is returned when the host requires acceptance of its Terms of Service and the request did not carry it, only when a current terms version is configured (MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/AuthErrorCodes.cs:12-26). - Depends on: nothing first-party; consumed as an
Errorcode string. - Concept introduced, one code for two different failure paths.
[Rubric §15, Best Practices & Code Quality]assesses whether a caller-facing contract stays stable across an implementation detail. Both the up-front existence check and the unique-index race recovery during registration return this same code, so the two paths stay indistinguishable to the caller (AuthErrorCodes.cs:14-15). The registration UI keys its "sign in instead" guidance on this code. - Walkthrough: two
public const stringfields,EmailAlreadyExists(AuthErrorCodes.cs:19) andTermsNotAccepted(AuthErrorCodes.cs:25). - Why it's built this way: a race between the pre-check and the database's unique constraint is possible under concurrent registration attempts; returning the same code from both paths means the caller-facing behavior does not depend on which path lost the race.
- Where it's used: returned by
AuthenticationServiceBase(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs) and asserted by the registration form's tests (MMCA.Common/Tests/Presentation/MMCA.Common.UI.Tests/Pages/Auth/RegisterFormTests.cs).TermsNotAcceptedis re-exposed asLegalAcceptanceErrors.TermsNotAcceptedCode(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Legal/LegalAcceptanceErrors.cs:14) and returned fromAuthenticationServiceBase.RegisterAsync(AuthenticationServiceBase.cs:238); see LegalAcceptanceErrors.
IPermissionCatalog
MMCA.Common.Shared ·
MMCA.Common.Shared.Auth.Permissions·MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Permissions/IPermissionCatalog.cs:24· Level 0 · interface
- What it is: the read-only enumeration half of the permission model: every role the compiled
registry grants something to, and every permission it grants to anyone, both sorted ordinally
(
MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Permissions/IPermissionCatalog.cs:24-31). - Depends on: nothing first-party; it is a companion abstraction to IPermissionRegistry.
- Concept introduced, separating "does this role grant that permission" from "what roles and
permissions exist at all".
[Rubric §1, SOLID]applies to interface segregation: IPermissionRegistry answers a per-request authorization question,IPermissionCataloganswers an administration-screen enumeration question, and a consumer that only needs to render a permission-grant matrix does not have to depend on the authorization interface at all. - Walkthrough: two get-only properties,
RolesandPermissions, bothIReadOnlyList<string>. - Why it's built this way: PermissionRegistry already holds the closed set of roles and permissions compiled into the host, so the catalog is a second, narrower interface the same class implements explicitly, rather than a second source of truth that could drift.
- Where it's used: implemented explicitly by PermissionRegistry; read by
StoredPermissionRoleAdministrationService(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/Administration/StoredPermissionRoleAdministrationService.cs),PermissionCatalogResponse(MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Responses/PermissionCatalogResponse.cs), andAuthorizationExtensions(MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/AuthorizationExtensions.cs); registered alongside IPermissionRegistry in bothMMCA.Common.ApplicationandMMCA.Common.InfrastructureDependencyInjection.cs.
IPermissionRegistry
MMCA.Common.Shared ·
MMCA.Common.Shared.Auth.Permissions·MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Permissions/IPermissionRegistry.cs:14· 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-14). - 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-13): 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:21) returns the permission set for a role, or an empty set for an unknown role, never a throw (IPermissionRegistry.cs:16-18).HasPermission(IEnumerable<string> roles, string permission)(IPermissionRegistry.cs:29) 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:73) and injected into PermissionAuthorizationHandler (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionAuthorizationHandler.cs:13).
IUserAdminDTO
MMCA.Common.Shared ·
MMCA.Common.Shared.Auth.Administration·MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Administration/IUserAdminDTO.cs:15· Level 0 · interface
- What it is: the shape the built-in user-administration list page renders a row from: account
id, email, role, and a locked flag
(
MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Administration/IUserAdminDTO.cs:15-31). - Depends on: the solution-wide
UserIdentifierTypealias forUserId. - Concept introduced, an app-supplied DTO shape behind a framework-defined contract.
[Rubric §1, SOLID]assesses dependency inversion for a UI component shipped by the framework but rendering app-owned data.UserAdminList.razor.cscannot know each app's concrete user-list DTO, so it depends on this interface instead; each app's own list DTO implements it.IsLockedis deliberately one boolean over two different underlying representations: the doc comment states ADC reads it offLockedOn is not nullwhile Store reads it off!IsActive(IUserAdminDTO.cs:20-24), so the list page renders identically regardless of which lockout model the app uses. - Walkthrough: four get-only properties:
UserId(the identifier the detail route and every administration call key on),Email(the row's primary label),Role(the account's single role), andIsLocked(whether the account is shut out of sign-in). - Why it's built this way: the built-in administration UI is framework code shared by every consumer, so it can only render through an abstraction each app's own user-list projection implements.
- Where it's used: consumed by
UserAdminList.razor.cs(MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Administration/UserAdminList.razor.cs) and registered byMMCA.Common.UI'sDependencyInjection.cs; implemented by ADC'sUserListDTO(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Shared/Users/UserListDTO.cs).
PasswordComplexity
MMCA.Common.Shared ·
MMCA.Common.Shared.Auth·MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/PasswordComplexity.cs:18· Level 0 · class (static partial)
- What it is: the one definition of the strong-password rule: the two length bounds, four
source-generated character-class regexes, and an
Evaluatepredicate that combines them (MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/PasswordComplexity.cs:5-18). - Depends on: nothing first-party; BCL
System.Text.RegularExpressionsand the[GeneratedRegex]source generator (PasswordComplexity.cs:1,34). - Concept introduced, one rule evaluated on both sides of the wire.
[Rubric §24, Forms, Validation & UX Safety]assesses validation parity: the client form must give the verdict the API would. The doc comment states the purpose directly (PasswordComplexity.cs:5-10): the server validator (StrongPasswordRules<T> in MMCA.Common.Application) and the client attribute (PasswordComplexityAttribute in MMCA.Common.UI) both read this type, so they cannot disagree on the same input. It lives in Shared because Shared is the one project both of those layers may reference (UI depends on Shared only). The character classes are Unicode-aware (PasswordComplexity.cs:11-16): an accented or CJK letter counts as a letter, never as the "special" character. - Walkthrough:
MinimumLengthis8(PasswordComplexity.cs:28) andMaximumLengthis128(PasswordComplexity.cs:31), bothconst intmeasured in UTF-16 code units, so they are usable in attribute arguments. Fourpublic static partial Regexproperties are source-generated by[GeneratedRegex], each with a 1000 ms match timeout:Uppercase(\p{Lu},PasswordComplexity.cs:34-35),Lowercase(\p{Ll},:38-39),Digit(\p{Nd},:42-43), andSpecialCharacter([^\p{L}\p{Nd}], anything that is neither a letter nor a decimal digit,:49-50).Evaluate(string? password)(PasswordComplexity.cs:52-57) returnstrueonly when the input is non-null, at leastMinimumLengthlong, and matches all four regexes. It deliberately does not checkMaximumLength: the upper bound is a separate rule with its own message on both sides (PasswordComplexity.cs:52-56). - Why it's built this way: a client rule and a server rule written separately are two copies of
one policy that drift independently. Exposing the regexes (not only
Evaluate) lets the server's FluentValidation chain keep one message per failed class, while the client attribute asks the single yes/no question. Source-generated regexes avoid runtime regex construction, and the match timeout bounds evaluation of untrusted input. - Where it's used:
StrongPasswordRules<T>feeds both length constants and all four regexes into its rule chain (MMCA.Common/Source/Core/MMCA.Common.Application/Validation/CommonValidationRules.cs:196-201); PasswordComplexityAttribute callsEvaluate, leaving empty input to[Required](MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/PasswordComplexityAttribute.cs:24); RegisterModel and ResetPasswordModel pair that attribute with[StringLength(PasswordComplexity.MaximumLength)]to supply the separate upper-bound rule (MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/RegisterModel.cs:27-28,MMCA.Common/Source/Presentation/MMCA.Common.UI/Pages/Auth/ResetPasswordModel.cs:25-26); pinned byMMCA.Common/Tests/Core/MMCA.Common.Shared.Tests/Auth/PasswordComplexityTests.cs. - Caveats / not-in-source: the server's error messages still hard-code the numbers ("at least 8
characters", "longer than 128 characters") rather than interpolating the constants
(
CommonValidationRules.cs:196-197), and so does the attribute's default message (PasswordComplexityAttribute.cs:18); changing a bound means editing those strings too. The plainPasswordRules<T>sibling in the same file still uses literal8/128(CommonValidationRules.cs:180-181) and does not read this type.
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, the caller's roles, an explicit permission claim, the refresh-session id, and whether a second authentication factor was presented (MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/ClaimsPrincipalExtensions.cs:6-18).Depends on: AuthClaimTypes for the claim names it reads (
ClaimsPrincipalExtensions.cs:28Subject,:98Permission,:112SessionId,:129,139MultiFactor); 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: eight extension methods, all null-tolerant by construction (every parameter is
ClaimsPrincipal?, so a null principal yields null, empty, or false 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.GetRoleValues(this ClaimsPrincipal?)(ClaimsPrincipalExtensions.cs:59-67) returns every role value the principal carries under any of three claim types: the standardClaimTypes.RoleURI, or the rawrole/rolesclaim an identity provider emits with inbound-claim mapping off. The remarks mark it SECURITY: it is the framework's one definition of "the caller's roles", because the narrower BCLClaimsPrincipal.IsInRole(string)sees only the identity's own role claim type and can therefore disagree with the authorization handler about who is privileged.HasRole(this ClaimsPrincipal?, string)(ClaimsPrincipalExtensions.cs:75-77) isGetRoleValues().Contains(role, StringComparer.OrdinalIgnoreCase), guarded against a blank role.HasPermissionClaim(this ClaimsPrincipal?, string)(ClaimsPrincipalExtensions.cs:94-97) checks for an AuthClaimTypes.Permissionclaim matching the given permission, independently of role. The remarks mark it SECURITY too: both authorization gates, the HTTP policy handler and the CQRS pipeline gate, read it, so a grant the minting host stored and emitted as a claim is honored identically at both boundaries; in a multi-service deployment the claim is the only way such a grant reaches a service that does not own the grant table. It is additive, never a denial: a principal without the claim still passes on a role the registry grants.FindSessionId(this ClaimsPrincipal?)(ClaimsPrincipalExtensions.cs:109-113) 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.HasMultiFactor(this ClaimsPrincipal?)(ClaimsPrincipalExtensions.cs:127-129) isprincipal?.FindFirst(AuthClaimTypes.MultiFactor) is not null. The remarks mark it SECURITY: presence is the whole test, and absence denies rather than falling back to a role check, because only the sign-in flow stamps the claim, and only after a code verified.FindMultiFactorMethod(this ClaimsPrincipal?)(ClaimsPrincipalExtensions.cs:137-139) returns the value of theMultiFactorclaim, ornullwhen the principal carries none, for audit and UI copy such as "signed in with a recovery code".
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:110-113): 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:116) 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:541); CurrentUserTargetingContextAccessor uses it for feature-flag targeting (MMCA.Common/Source/Presentation/MMCA.Common.API/FeatureManagement/CurrentUserTargetingContextAccessor.cs:17,86); the opt-in "UserPolicy" rate-limit partition keys onhttpContext.User?.Identity?.Namedirectly rather than onGetUserId()(MMCA.Common/Source/Presentation/MMCA.Common.API/Startup/WebApplicationBuilderExtensions.RateLimiting.cs:176-181); AuthControllerBase usesFindSessionId()to tell the session list which row is the caller's own (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:195); AuthenticationServiceBase<TUser> usesGetUserId()on the principal recovered from an expired access token during rotation (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:330-333); 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);GetRoleValues/HasRoleback the CQRS pipeline'sAuthorizationdecorator gate;HasPermissionClaimbacks both the HTTPPermissionAuthorizationHandlerand the CQRS gate so a claim-carried grant is honored identically at either boundary;HasMultiFactorbacks the CQRS gate'sIRequiresMfacheck.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:16· Level 1 · class (sealed)
- What it is: the immutable, thread-safe implementation of
IPermissionRegistry and, since the catalog interface shipped, of
IPermissionCatalog too, backed by a frozen role-to-permissions map
(
MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Permissions/PermissionRegistry.cs:16-20). - Depends on: IPermissionRegistry and
IPermissionCatalog;
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:34-37). - Walkthrough: a shared empty
FrozenSetsentinel and the frozen map field, plus twoIReadOnlyList<string>backing fields for the catalog,_rolesand_permissions(PermissionRegistry.cs:19-23). The constructor (PermissionRegistry.cs:30) guards its argument, freezes the supplied map with the two comparers (PermissionRegistry.cs:34), then materializes_roles(the map's keys, ordinally sorted) and_permissions(every permission across every role, deduplicated and ordinally sorted) once, at construction (PermissionRegistry.cs:41-48); the comment explains why: the registry is immutable, so computing the sorted lists once avoids re-sorting the whole map on every request an administration screen makes.RolesandPermissions(PermissionRegistry.cs:55,58) implement IPermissionCatalog explicitly, with a comment explaining that the catalog'sPermissionsand the registry'sGetPermissions(role)answer different questions, so a caller holding the concrete type should not have to tell them apart (also Sonar CA1721); the catalog is meant to be consumed through theIPermissionCataloginterface.GetPermissions(PermissionRegistry.cs:61-65) 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:67) guards its inputs (PermissionRegistry.cs:70-71), then walks the principal's roles and returns on the first role whose set contains the permission (PermissionRegistry.cs:73-83), 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). Adding IPermissionCatalog to the same class rather than a second type means the administration screen's "what roles and permissions exist" question is answered from the exact data structure the authorization question is answered from, so the two cannot drift apart.
- 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:71-73); read by PermissionAuthorizationHandler (MMCA.Common/Source/Presentation/MMCA.Common.API/Authorization/PermissionAuthorizationHandler.cs:14,31); its IPermissionCatalog side is read byStoredPermissionRoleAdministrationServiceand exposed throughPermissionCatalogResponse.
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:116-139); modules reach it throughAddPermissions(...), which is deliberately safe to call once per module (AuthorizationExtensions.cs:98-113), as MMCA.ADC's Conference, Engagement, and Identity modules each do (MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.API/DependencyInjection.cs:41-46,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:101), but nothing enforces it at runtime.
RoleValue
MMCA.Common.Shared ·
MMCA.Common.Shared.Auth·MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/RoleValue.cs:26· 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-26).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:17). 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:18-24). 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:91-94), 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:18,78-84). - 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:6), 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:12-17). 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:34).
[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:29) assigned by the protected constructor (RoleValue.cs:33), so an instance is immutable and only a derived type can create one. The staticValidate(role, knownRoles, source)(RoleValue.cs:43) null-guards the supplied set (RoleValue.cs:45), 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:47-53). The membership test is the privateIsKnown(RoleValue.cs:64-66) 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:56-63). 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:76) freezes the supplied singletons into a case-insensitiveFrozenDictionarykeyed byValue(RoleValue.cs:81-84), so a derived type can back itsFromString/IsValidmembers with interned instances instead of re-allocating on every parse.ToStringreturns the value (RoleValue.cs:88), andGetHashCodeuses the ordinal-ignore-case hash (RoleValue.cs:97) 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:18-24) 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:24), theOrdinalIgnoreCasecase (RoleValueTests.cs:36), a null role (RoleValueTests.cs:65), and a null role set throwing (RoleValueTests.cs:73).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:21-34), exposesFromStringreturning aResult<UserRole>andIsValidover that frozen lookup (UserRole.cs:52-66), and adds a case-insensitiveIsOrganizerfor raw claim strings (UserRole.cs:76) plus the sealed-typeIEquatable<UserRole>and==/!=operators the base leaves to subclasses (UserRole.cs:78-90). 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).
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.
ConfirmEmailRequest
MMCA.Common.Shared ·
MMCA.Common.Shared.Auth.Requests·MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Requests/EmailConfirmationRequests.cs:19· Level 0 · record struct (readonly)
- What it is:
(string Email, string Token), the payload that redeems a single-use email-confirmation token (MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Requests/EmailConfirmationRequests.cs:14-21). - Depends on: nothing first-party. It pairs with SendEmailConfirmationRequest, which triggers the mail this one redeems.
- Concept: the
readonly record structrequest shape from LoginRequest.[Rubric §11, Security]: like ResetPasswordRequest, the address and token are carried in the URI fragment, so ADC's confirm-email page reads them client-side and posts them here; neither value ever reaches a server log or aRefererheader, per the shared base controller action's own doc comment (EmailConfirmationControllerBase,MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/EmailConfirmationControllerBase.cs:97-101). - Walkthrough: two positional parameters (
EmailConfirmationRequests.cs:19-21); no body. - Where it's used: shape-validated by
ConfirmEmailRequestValidator; handled through
ConfirmEmailCommand(MMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.Application/Users/UseCases/ConfirmEmail/ConfirmEmailCommand.cs) by the shared ConfirmEmailHandlerBase; exposed by ADC'sEmailConfirmationController's anonymous, rate-limited, idempotentPOST confirm-email(inherited fromEmailConfirmationControllerBase.cs:106-125), which answers 204 on success; posted by ADC'sEmailConfirmationServiceUI client. - Caveats / not-in-source: email confirmation is send-only in ADC today
(
Authentication:EmailConfirmation:RequireConfirmedEmailstaysfalseeverywhere), so redeeming this request updates the stored confirmation state but never gates sign-in; Store does not wire this endpoint.
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:226,234). - 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).
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), plus an init-onlyTwoFactorCodeproperty carrying the second-factor code when the account has two-factor authentication enabled, either the authenticator-app code or a single-use recovery code (LoginRequest.cs:8-15,20-24). The doc remark explains why it is a property rather than a third positional parameter: a positional parameter would widen the generatedDeconstruct, a source break for any caller that destructures a login request, while a property leaves the constructor, the deconstruction, and the JSON shape of every existing caller untouched, and a client that never sends the field simply leaves itnull(LoginRequest.cs:16-19). Omitting it on the first attempt is the normal flow: sign-in answersAuthentication.TwoFactorRequiredwhen the account needs a code and none arrived, and the client retries the same credentials with the code filled in (LoginRequest.cs:21-23). - 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:123-124), 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:175-182). 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:238-241), looks the code up in ICacheService (OAuthControllerBase.cs:243-277), and then removes it so a replayed code cannot mint a second token pair (OAuthControllerBase.cs:279-280); an unknown, burned, or expired code all return the same HTTP 400 with a deliberately non-specific message (OAuthControllerBase.cs:285-288). 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:231). - Where it's used: the body of the OAuth
exchangeendpoint (OAuthControllerBase.cs:230,234-236), called by the UI's/auth/oauth-completepage after the provider redirect lands (OAuthControllerBase.cs:185-188,191-192).
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:309-310), 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:326-327,333-337); exposed by AuthControllerBase'sPOST refresh(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:124,130-131).
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:76-78). 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,79-83,88-92,116-120). One ordering decision is worth internalizing: the token is consumed before the save, and the comment says why (ResetPasswordHandlerBase.cs:73-75), 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:94-95,101,109-110); posted by AuthUIService'sResetPasswordAsync(MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/AuthUIService.cs:243,253). 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).
SendEmailConfirmationRequest
MMCA.Common.Shared ·
MMCA.Common.Shared.Auth.Requests·MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Requests/EmailConfirmationRequests.cs:12· Level 0 · record struct (readonly)
- What it is: a single-field request
(string Email)that asks for a fresh confirmation link to be emailed to an address (MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Requests/EmailConfirmationRequests.cs:3-12). - Depends on: nothing first-party. It is the anti-enumeration sibling of
ForgotPasswordRequest: the doc comment says this endpoint is anonymous by
necessity (an unconfirmed account may not be able to sign in at all) and answered identically whether
or not the address holds an account, for the same reason the forgot-password endpoint is
(
EmailConfirmationRequests.cs:6-9). - Concept: see ForgotPasswordRequest for the anti-enumeration contract this reuses; not re-taught here.
- Walkthrough: one positional
string Email(EmailConfirmationRequests.cs:12); no body. - Where it's used: shape-validated by
SendEmailConfirmationRequestValidator; bound as the body of
the anonymous, rate-limited, idempotent
POST send-email-confirmationaction, which returns202 Acceptedon every well-formed request (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/EmailConfirmationControllerBase.cs:78-96, which ADC inherits throughMMCA.ADC/Source/Modules/Identity/MMCA.ADC.Identity.API/Controllers/EmailConfirmationController.cs:29-34); carried bySendEmailConfirmationCommand(EmailConfirmationController.cs:37), handled by the shared SendEmailConfirmationHandlerBase. ADC's ownAuthController.RegisterAsyncschedules the send as part of registration rather than calling this endpoint directly.
SetRolePermissionsRequest
MMCA.Common.Shared ·
MMCA.Common.Shared.Auth.Requests·MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Requests/AdministrationRequests.cs:23· Level 0 · record (sealed)
- What it is:
(IReadOnlyList<string> Permissions), the payload that replaces the complete set of stored permission grants for one role (MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Requests/AdministrationRequests.cs:14-23). - Depends on: nothing first-party.
- Concept introduced, replace rather than add/remove.
[Rubric §9, API & Contract Design]: the doc remark explains the shape choice, a replace rather than an add/remove pair makes the request idempotent (re-submitting the same list is a no-op), and these are the stored grants only: permissions compiled into the host'sIPermissionRegistryare not editable and are not returned here, so an operator can never remove a capability the code depends on by editing data (AdministrationRequests.cs:17-20). - Walkthrough: one positional
IReadOnlyList<string> Permissionson asealed record(AdministrationRequests.cs:23); no body. - Where it's used: bound as the body of
RolesAdminControllerBase's
SetPermissionsaction (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Administration/RolesAdminControllerBase.cs:145); shape-validated by SetRolePermissionsRequestValidator, which requires a non-null list and validates each entry (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/AdministrationRequestValidators.cs:30,37,39); consumed by IRoleAdministrationService and posted by the Common UI'sRoleAdminService(MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Administration/RoleAdminService.cs).
SetUserRolesRequest
MMCA.Common.Shared ·
MMCA.Common.Shared.Auth.Requests·MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Requests/AdministrationRequests.cs:12· Level 0 · record (sealed)
- What it is:
(IReadOnlyList<string> Roles), the payload that replaces the complete set of roles an account holds (MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Requests/AdministrationRequests.cs:3-12). - Depends on: nothing first-party.
- Concept: the same replace-not-patch shape as
SetRolePermissionsRequest; see that section for the idempotency
rationale. The doc remark restates it in this type's own terms: the administration UI edits a list,
and a replace makes re-submitting the same list a no-op, where an add/remove pair would need the
caller to know what the account already held (
AdministrationRequests.cs:6-9). - Walkthrough: one positional
IReadOnlyList<string> Roleson asealed record(AdministrationRequests.cs:12); no body. - Where it's used: bound as the body of
UsersAdminControllerBase<TUserDto>'s
SetRolesaction (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Administration/UsersAdminControllerBase.cs:177); shape-validated by SetUserRolesRequestValidator, which requires a non-null list and validates each entry (AdministrationRequestValidators.cs:11,18,20); consumed by IUserAdministrationService<TUserDto> and posted by the Common UI'sUserAdminService(MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Administration/UserAdminService.cs).
TwoFactorCodeRequest
MMCA.Common.Shared ·
MMCA.Common.Shared.Auth.Requests·MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Requests/TwoFactorCodeRequest.cs:16· Level 0 · record struct (readonly)
- What it is: a single-field request
(string Code), the live-code proof shared by every two-factor action that has to be proved with a code: confirming enrollment, disabling the second factor, and regenerating the recovery codes (MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Requests/TwoFactorCodeRequest.cs:3-16). - Depends on: nothing first-party.
- Concept introduced, one payload for three actions.
[Rubric §9, API & Contract Design]and[Rubric §11, Security]: the doc remark explains why one shape serves three actions rather than three identical ones, the code is the whole request body in each case and the account is taken from the authenticated caller, never from the request; disable and regenerate demand a code for the same reason enrollment does, so whoever holds a stolen access token still cannot strip the account's second factor with it (TwoFactorCodeRequest.cs:7-11). Each of the three handler bases enforces the shared shape through the same generic constraint,where TCommand : IUserScopedCommand<TwoFactorCodeRequest>(MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/TwoFactor/ConfirmTwoFactorEnrollmentHandlerBase.cs:33,MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/TwoFactor/DisableTwoFactorHandlerBase.cs:33,MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/TwoFactor/RegenerateRecoveryCodesHandlerBase.cs:35). - Walkthrough: one positional
string Code(TwoFactorCodeRequest.cs:16); no body. - Where it's used: shape-validated by
TwoFactorCodeRequestValidator
(
MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/TwoFactorCodeRequestValidator.cs:16,23); handled by the threeIUserScopedCommand<TwoFactorCodeRequest>handler bases above, each layered over ITwoFactorService and ITwoFactorAuthenticator.
RegisterRequest
MMCA.Common.Shared ·
MMCA.Common.Shared.Auth.Requests·MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Requests/RegisterRequest.cs:20· Level 4 · record struct (readonly)
- What it is: the registration payload for a new account: email, password, first and last name,
an optional postal address, and a terms-acceptance flag
(
MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Requests/RegisterRequest.cs:5-19). - 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:25) 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: six positional parameters and no body (
RegisterRequest.cs:20-26): four strings, the nullableAddress, andbool AcceptedTerms = false(RegisterRequest.cs:26).AcceptedTermsis deliberately a plain flag rather than a version string: the register page is anonymous and cannot read the current terms version, so the server stamps the version it has configured (RegisterRequest.cs:13-19).RegisterAsyncrefuses the request withLegalAcceptanceErrors.TermsNotAcceptedonly when the host configured aCurrentTermsVersionand the flag is false; with no terms version configured the flag is ignored (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:233-239), and an overridingCreateUserstampsCurrentTermsVersionon the new user (AuthenticationServiceBase.cs:105-120). 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:473). 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:255-256).[Rubric §11, Security]. - Where it's used:
AuthenticationServiceBase<TUser>.RegisterAsync(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationServiceBase.cs:221-222), theregisterendpoint onAuthControllerBase, which binds it[FromBody]on an anonymous, rate-limited, idempotent POST (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/AuthControllerBase.cs:93-103), 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:139-146).
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:269-274). - 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:201,289,500,520) and directly at the end of a rotation (AuthenticationServiceBase.cs:304); 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,127); consumed by AuthUIService, DirectApiTokenRefresher, and CookieSessionRefresher.
FeatureFlagLifetime
MMCA.Common.Shared ·
MMCA.Common.Shared.FeatureFlags·MMCA.Common/Source/Core/MMCA.Common.Shared/FeatureFlags/FeatureFlagLifetime.cs:9· Level 0 · enum
- What it is: the two-value lifetime a feature flag declares itself as,
PermanentorTemporary(MMCA.Common/Source/Core/MMCA.Common.Shared/FeatureFlags/FeatureFlagLifetime.cs:9-22). - Depends on: nothing; consumed by
FeatureFlagAttributeandFeatureFlagDescriptor. - Concept introduced, the flag with a stated exit.
[Rubric §31, Technical Debt Management]assesses whether a temporary decision carries its own expiry rather than accumulating silently.Permanentis "a capability a host chooses to run with or without (push notifications, a data-subject export endpoint)" and must NOT carry a removal date (FeatureFlagLifetime.cs:11-15);Temporary"exists only until a migration, rollout or experiment finishes", must carry a removal date, and the build fails once that date has passed (:17-21). The enforcement itself lives onFeatureFlagAttributeand the governance architecture rule that reads it. - Walkthrough: two members,
Permanent(FeatureFlagLifetime.cs:15) andTemporary(:21), each documented inline with the consequence it carries. - Why it's built this way: ADR-031 is the feature-flag management scheme this lifetime enforces.
- Where it's used: the
Lifetimeargument every[FeatureFlag]attribute application supplies, acrossMMCA.ADC/Source/Modules/Engagement/MMCA.ADC.Engagement.Shared/EngagementFeatures.cs,MMCA.ADC/Source/Modules/Conference/MMCA.ADC.Conference.Shared/ConferenceFeatures.cs,MMCA.Common/Source/Core/MMCA.Common.Shared/Notifications/NotificationFeatures.cs, andMMCA.Common/Source/Core/MMCA.Common.Shared/Privacy/PrivacyFeatures.cs; read back byFeatureFlagRegistry.Describeinto eachFeatureFlagDescriptor.Lifetime, and by the governance architecture rule that fails aTemporaryflag past itsRemoveBydate (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Rules/Governance/ArchitectureRules.FeatureFlags.cs).
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:45) and composes theUidassession-{id}@atldevcon(CalendarExportMapper.cs:29-42, the id at:38). The mapped entries reachExportSessionCalendarHandler(.../ExportCalendar/ExportSessionCalendarHandler.cs:52-55) 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.
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:89,.../LoggingCommandDecorator.cs:76) and push it into the log scope (LoggingQueryDecorator.cs:31,82,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:84-88). 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 (.../DataSources/Engines/CosmosDataSourceEngine.cs:95), 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).
PermissionCatalogResponse
MMCA.Common.Shared ·
MMCA.Common.Shared.Auth.Responses·MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Responses/PermissionCatalogResponse.cs:22· Level 0 · record (sealed)
- What it is: the full permission catalog for the admin role editor: every registered role name and
every registered permission name, unpaired
(
MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Responses/PermissionCatalogResponse.cs:22-24). - Depends on: nothing first-party at compile time.
- Concept: a reference-data response, distinct from
RolePermissionsResponsewhich pairs one role with its grants. This one is the lookup lists a role editor needs to populate a picker before a role has been chosen. - Walkthrough: two positional parameters on a
sealed record,RolesandPermissions, bothIReadOnlyList<string>(PermissionCatalogResponse.cs:22-24); no body. - Why it's built this way: ADR-116 is the role/permission administration surface this response is part of.
- Where it's used: built by
StoredPermissionRoleAdministrationService.GetCatalogAsyncfrom the registered roles and the compiledPermissionRegistry'sPermissions(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/Administration/StoredPermissionRoleAdministrationService.cs:80-89); declared as the 200 response ofGET .../catalogonRolesAdminControllerBase(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Administration/RolesAdminControllerBase.cs:99-102); fetched client-side byRoleAdminServicefor the role admin pages.
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:76) and the rotation linkReplacedByTokenHash(RefreshSession.cs:91). 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:201-204). 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:527), 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:412-419); 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:183-195); fetched client-side by AuthUIService.GetSessionsAsync(MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Auth/AuthUIService.cs:261,268) 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:41,122-124,237). - 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.
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:111,122,142,CookieSessionRefresheratMMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieSessionRefresher.cs:159,IdempotencyFilteratMMCA.Common/Source/Presentation/MMCA.Common.API/Idempotency/IdempotencyFilter.cs:207, and theICacheServiceGetOrCreateAsyncdefault implementation atMMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/ICacheService.cs:166.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:179, 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.
RolePermissionsResponse
MMCA.Common.Shared ·
MMCA.Common.Shared.Auth.Responses·MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Responses/RolePermissionsResponse.cs:19· Level 0 · record (sealed)
- What it is: one role's permission picture: its name, the permissions its registered policies
compile to, and the permissions actually stored as an override, side by side
(
MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Responses/RolePermissionsResponse.cs:19-22). - Depends on: nothing first-party at compile time; it is the response shape
StoredPermissionRoleAdministrationServicebuilds fromPermissionRegistryand its permission-grant store. - Concept:
RegisteredPermissionsversusStoredPermissionsis the diff a role editor renders: the compiled default from code-registered policies against whatever an administrator has overridden in the database. Carrying both, rather than one merged list, is what lets the UI show which permissions are the framework default and which were changed. - Walkthrough: three positional parameters on a
sealed record,Role,RegisteredPermissions,StoredPermissions(RolePermissionsResponse.cs:19-22); no body. - Why it's built this way: no ADR is specific to this response shape; it follows the same DTO
convention (
sealed record, positional, immutable) as every response in this group. - Where it's used: built three times by
StoredPermissionRoleAdministrationService, once per role for the list view (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/Administration/StoredPermissionRoleAdministrationService.cs:70-72), once for a single-role read (:116), and once after a permissions update (:190); declared as the 200 response of the list, get, and set-permissions endpoints onRolesAdminControllerBase(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Administration/RolesAdminControllerBase.cs:77-143); consumed byRoleAdminServiceand rendered on ADC's role list and role edit pages.
TwoFactorRecoveryCodesResponse
MMCA.Common.Shared ·
MMCA.Common.Shared.Auth.Responses·MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Responses/TwoFactorResponses.cs:41· Level 0 · record (sealed)
- What it is: the one-time list of recovery codes handed back after two-factor enrollment is
confirmed or after codes are regenerated
(
MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Responses/TwoFactorResponses.cs:41). - Depends on: nothing first-party at compile time.
- Concept: shown to the user exactly once, at the moment it is minted; nothing in the type marks that, the "show once" rule is procedural, not enforced by the shape.
- Walkthrough: a single positional parameter,
RecoveryCodes(IReadOnlyList<string>), no body (TwoFactorResponses.cs:41). - Why it's built this way: follows the same DTO convention as its siblings in this file.
- Where it's used: produced by
ConfirmTwoFactorEnrollmentHandlerBase.HandleAsyncafter a successful enrollment (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/TwoFactor/ConfirmTwoFactorEnrollmentHandlerBase.cs:72) and byRegenerateRecoveryCodesHandlerBase.HandleAsyncafter a code regeneration (.../TwoFactor/RegenerateRecoveryCodesHandlerBase.cs:76), both wrapping the codes returned from the two-factor store. - Caveats / not-in-source: nothing in the response marks the codes as single-display; the UI, not this type, is responsible for not persisting or re-showing them.
TwoFactorSetupResponse
MMCA.Common.Shared ·
MMCA.Common.Shared.Auth.Responses·MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Responses/TwoFactorResponses.cs:27· Level 0 · record struct (readonly)
- What it is: the payload of beginning two-factor enrollment: the TOTP shared key and the
provisioning URI an authenticator app scans as a QR code
(
MMCA.Common/Source/Core/MMCA.Common.Shared/Auth/Responses/TwoFactorResponses.cs:27-29). - Depends on: nothing first-party; follows the
readonly record structDTO shapeAuthenticationResponseintroduces. - Concept: the same value-type carrier convention as
AuthenticationResponse; no new concept. - Walkthrough: two positional parameters,
SharedKeyandProvisioningUri, no body (TwoFactorResponses.cs:27-29). - Why it's built this way: value semantics for a small, short-lived response, consistent with the rest of this file's auth DTOs.
- Where it's used: produced by
BeginTwoFactorEnrollmentHandlerBase.HandleAsyncat the end of a successful enrollment start (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/TwoFactor/BeginTwoFactorEnrollmentHandlerBase.cs:89).
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).
FeatureFlagAttribute
MMCA.Common.Shared ·
MMCA.Common.Shared.FeatureFlags·MMCA.Common/Source/Core/MMCA.Common.Shared/FeatureFlags/FeatureFlagAttribute.cs:32· Level 1 · class (sealed)
- What it is: the attribute a
*Featuresconstant class applies to each flag field, carrying aFeatureFlagLifetime, an optionalRemoveBydate, and an optionalOwner(MMCA.Common/Source/Core/MMCA.Common.Shared/FeatureFlags/FeatureFlagAttribute.cs:32-67). - Depends on:
FeatureFlagLifetime;System.Attribute,System.DateOnly,System.Globalization.CultureInfo(BCL). - Concept introduced, self-describing metadata a reflection scan can enforce.
[Rubric §31, Technical Debt Management]assesses whether a "temporary" label is actually checkable.RemoveByis astring?rather than aDateOnly?because "an attribute argument must be a compile-time constant" (FeatureFlagAttribute.cs:44), soTryParseRemoveBy(:60-66) is what turns the constant string back into a real date for the check, parsed strictly againstRemoveByFormat = "yyyy-MM-dd"(:35,63).Ownerexists purely for the failure message: "Carried into the failure message, so an expired flag names someone" (:50), which is what letsFeatureFlagRegistryand the governance architecture rule point at a person rather than an anonymous build break. - Walkthrough: a primary constructor takes
lifetime(:32), stored read-only asLifetime(:38).RemoveByandOwnerareinit-only properties (:46,52), settable only as named attribute arguments.TryParseRemoveByis the one static method: a thin wrapper overDateOnly.TryParseExactwithDateTimeStyles.None(:60-66), so a malformed date fails to parse rather than being silently coerced. - Why it's built this way: ADR-031 is the flag lifecycle this attribute encodes: pairing every flag with an explicit permanent/temporary choice and, for temporary ones, a checkable expiry.
- Where it's used: applied to every flag field in the
*Featuresconstant classes; read byFeatureFlagRegistry.DescribeviaGetCustomAttribute<FeatureFlagAttribute>(MMCA.Common/Source/Core/MMCA.Common.Shared/FeatureFlags/FeatureFlagRegistry.cs) and by the governance architecture rule that fails the build on an expiredTemporaryflag (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Rules/Governance/ArchitectureRules.FeatureFlags.cs).
FeatureFlagDescriptor
MMCA.Common.Shared ·
MMCA.Common.Shared.FeatureFlags·MMCA.Common/Source/Core/MMCA.Common.Shared/FeatureFlags/FeatureFlagRegistry.cs:17· Level 1 · record (sealed)
- What it is: one row of
FeatureFlagRegistry.Describe's output: a flag's field name, wire name, and theFeatureFlagLifetime/RemoveBy/Ownerits[FeatureFlag]attribute carries, if any (MMCA.Common/Source/Core/MMCA.Common.Shared/FeatureFlags/FeatureFlagRegistry.cs:17-22). - Depends on:
FeatureFlagLifetime. - Concept:
Lifetime,RemoveBy, andOwnerare all nullable (:20-22) because a flag field is legal without the[FeatureFlag]attribute at all: the descriptor still reports the field and its constant value, just with no lifecycle metadata attached. That is what lets the governance rule and the inventory both work off one uniform shape whether or not every flag has opted in to the attribute. - Walkthrough: five positional parameters and no body (
FeatureFlagRegistry.cs:17-22):FieldName(the CLR field),FlagName(the constant's string value), then the three attribute-sourced, nullable fields. - Why it's built this way: ADR-031 is the scheme this record inventories.
- Where it's used: produced by
FeatureFlagRegistry.Describe, one per flag field; not referenced outside the defining file otherwise.
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:52-55) 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 now pass an injected
TimeProvider'sGetUtcNow()fordtStamp(ExportSessionCalendarHandler.cs:55,ExportEventCalendarHandler.cs:54), so the determinism the builder guarantees is exercised through the handlers as well as the builder's own tests.
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:565,586).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:35-38).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:91),CookieSessionRefresher(MMCA.Common/Source/Presentation/MMCA.Common.API/SessionCookies/CookieSessionRefresher.cs:88),MemoryCacheService(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Caching/MemoryCacheService.cs:45), theCacheKeyLocksholder behindICacheService'sGetOrCreateAsyncdefault implementation (MMCA.Common/Source/Core/MMCA.Common.Application/Interfaces/ICacheService.cs:196-199), and theQueryCacheKeyLocksholder behindCachingQueryDecorator<TQuery, TResult>(MMCA.Common/Source/Core/MMCA.Common.Application/UseCases/Decorators/CachingQueryDecorator.cs:247-250).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.
FeatureFlagRegistry
MMCA.Common.Shared ·
MMCA.Common.Shared.FeatureFlags·MMCA.Common/Source/Core/MMCA.Common.Shared/FeatureFlags/FeatureFlagRegistry.cs:35· Level 2 · class (static)
- What it is: the reflection scanner that turns a set of
*Featuresconstant classes into a flat, ordered list ofFeatureFlagDescriptorrows, given the assemblies to scan (MMCA.Common/Source/Core/MMCA.Common.Shared/FeatureFlags/FeatureFlagRegistry.cs:35-124). - Depends on:
FeatureFlagDescriptor,FeatureFlagAttribute;System.Reflection(BCL). - Concept introduced, the flag inventory built entirely by convention.
[Rubric §31, Technical Debt Management]assesses whether the set of flags in play is discoverable rather than tribal knowledge. A*Featuresclass is recognized purely by shape:IsFeatureClassrequires astatic, abstract, sealedtype (the C# pattern for a static class) whose name ends inFeatureClassSuffix = "Features"(:38,77,81-82);FlagFieldsthen takes only itspublic const stringfields (:91,95-97). No registration call, no central list to keep in sync: adding a flag is adding aconst stringfield to the module's*Featuresclass. - Walkthrough:
Describe(IEnumerable<Assembly>)(:46) scans every loadable type across the given assemblies, filters to feature classes, flattens to their flag fields, maps each to a descriptor, and orders the result byFlagNamewithStringComparer.Ordinal(:50-55) so the inventory is deterministic regardless of assembly load order.Describe(Assembly)(:64) is the single-assembly overload, delegating to the collection one (:68).LoadableTypes(:112) guardsAssembly.GetTypes()againstReflectionTypeLoadException, falling back to the subset of types that did load viaex.Types.OfType<Type>()(:118-121), so one broken type in an assembly does not blank out every flag in it.- The private
Describe(FieldInfo)(:100) reads the optionalFeatureFlagAttributewithinherit: false(:102) and the field's raw constant value viaGetRawConstantValue(), defaulting to an empty string (:106).
- Why it's built this way: ADR-031
is the convention this registry executes; scanning by name suffix rather than a marker interface
keeps a
*Featuresclass a plain constant holder with no base type or registration boilerplate. - Where it's used:
MMCA.Common/Tests/Core/MMCA.Common.Shared.Tests/FeatureFlags/FeatureFlagRegistryTests.csexercisesDescribe,IsFeatureClass, andFlagFieldsdirectly; the governance architecture rule (MMCA.Common/Source/Hosting/MMCA.Common.Testing.Architecture/Rules/Governance/ArchitectureRules.FeatureFlags.cs) callsDescribeacross the loaded module assemblies to enforce theTemporary/RemoveByexpiry rule. - Caveats / not-in-source:
FeatureClassSuffixis a public constant ("Features") but nothing enforces that every constant class ending in that suffix is actually meant to hold flags; a coincidentally-named class would be scanned as one.
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:417) writesResponse.Headers[ConcurrencyETag.ETagHeaderName] = ConcurrencyETag.Format(rowVersion)(EntityControllerBase.cs:425) after a successful by-id read (EntityControllerBase.cs:382), 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:211) and attaches it asIf-Match(EntityServiceBase.cs:424), 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).
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:74), reads it in the one helper both filter stages share (IdempotencyFilter.cs:166), and appends the replay header when it serves a cached response (IdempotencyFilter.cs:384);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:413), 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).
StronglyTypedIdValueParserDelegate<TValue>
MMCA.Common.Shared ·
MMCA.Common.Shared.Identifiers·MMCA.Common/Source/Core/MMCA.Common.Shared/Identifiers/StronglyTypedId.cs:161· Level 0 · delegate
- What it is: an internal
TryParse-shaped delegate,bool (string? s, IFormatProvider? provider, out TValue value)(StronglyTypedId.cs:158-162), the signatureStronglyTypedIdValueParser<TValue>binds a concrete parser method to. - Depends on: nothing beyond the closing generic constraint
TValue : notnull. - Concept introduced: none on its own; it exists so
StronglyTypedIdValueParser<TValue>can cache a bound method as a typed field instead of an untypedDelegate. - Where it's used: bound once per closed
TValuebyStronglyTypedIdValueParser<TValue>'sBuild(), and invoked byStronglyTypedId.TryParse.
StronglyTypedIdValueParser<TValue>
MMCA.Common.Shared ·
MMCA.Common.Shared.Identifiers·MMCA.Common/Source/Core/MMCA.Common.Shared/Identifiers/StronglyTypedId.cs:173· Level 1 · class (static)
- What it is: the per-closed-
TValuecache of a parser delegate, exposed as the single static fieldInstance(StronglyTypedId.cs:173-221). - Depends on:
StronglyTypedIdValueParserDelegate<TValue>. - Concept introduced, the two supported primitive shapes. A wrapped primitive parses one of two
ways:
stringtakes a direct identity path, the route segment IS the value (ParseString,:203-207), and every other supported primitive implementsIParsable<T>and is bound through reflection toParseParsable<TParsable>(:209).stringdoes implementIParsable<string>too, but parsing a string is the identity, soBuild()takes the direct path ahead of the reflective binding (comment at:188-189).Build()(:186) returnsnullfor aTValuethat is neither, which is what letsStronglyTypedId.CanParseValues<TValue>()answer "unsupported" without throwing. - Walkthrough:
Instance(:180) runsBuild()once, at type-initialization time for the closed generic.Build()checkstypeof(TValue) == typeof(string)first (:190-191), then testsIParsable<>.MakeGenericType(typeof(TValue)).IsAssignableFrom(typeof(TValue))(:193-194) and returnsnullwhen neither holds. For theIParsablebranch it reflects the privateParseParsablemethod off itself, closes it overTValue, and binds it withCreateDelegate(:196-200), a[SuppressMessage]d use of reflection the attribute justification explains: bound once per closed generic, never per request (:182-185). - Why it's built this way: ADR-115 is the governing record for the opt-in strongly typed identifier feature this class supports.
- Where it's used: exclusively by
StronglyTypedId.TryParse(StronglyTypedId.cs:67) andCanParseValues(StronglyTypedId.cs:87); nothing outside the defining file references it directly.
IStronglyTypedId<TSelf, TValue>
MMCA.Common.Shared ·
MMCA.Common.Shared.Identifiers·MMCA.Common/Source/Core/MMCA.Common.Shared/Identifiers/IStronglyTypedId.cs:60· Level 2 · interface
- What it is: the opt-in contract a value-type identifier implements to become a "strongly typed
id": a wrapper struct carrying one primitive
TValue, parsable from route/query text the same way the primitive itself is.TSelfis the curiously-recurring self type,TValuethe wrapped primitive (IStronglyTypedId.cs:60-63). - Depends on:
IParsable<TSelf>from the BCL, the interface it extends (IStronglyTypedId.cs:60); its two default methods delegate toStronglyTypedId.Parse/TryParse. - Concept introduced, strongly typed identifiers as an opt-in alternative to the identifier-type-alias
convention. Where a module's usual identifier is a
global using OrderId = ...alias over the primitive (no compile-time distinction between anOrderIdand any otherGuid), a type implementing this interface is a real value type the compiler tells apart fromGuidorint, at the cost of writing the wrapper.[Rubric §4, DDD]assesses whether identity is modeled as a first-class value rather than a primitive;[Rubric §9, API & Contract Design]covers whether route/query binding for the stronger type is as seamless as for the primitive it replaces, which is exactly what the two default interface methods buy: a wrapper writes onlyFrom, and route-model binding works through the inheritedIParsable<TSelf>.Parse/TryParse. - Walkthrough:
Value(:155) exposes the wrapped primitive.static abstract TSelf From(TValue value)(:162) is the one member an implementation must write, wrapping a primitive into the identifier. The two remaining members are default implementations ofIParsable<TSelf>, so a wrapper never writes them:Parse(:172-173) andTryParse(:184-188) both delegate straight toStronglyTypedId's staticParse/TryParseoverloads, closed overTSelf, TValue. - Why it's built this way:
ADR-085 revisits
the plain-alias identifier convention, and
ADR-115 records the
strongly-typed alternative as opt-in rather than a replacement: a module keeps its
global usingaliases by default and reaches for this interface only where the extra compile-time safety is worth the wrapper. - Where it's used: implemented by identifier structs registered with
StronglyTypedIdRegistry; the constraintwhere TSelf : struct, IStronglyTypedId<TSelf, TValue>recurs acrossStronglyTypedId,StronglyTypedIdMappings<TSelf, TValue>,StronglyTypedIdTypeConverter<TSelf, TValue>andStronglyTypedIdConverter<TSelf, TValue>. Framework coverage:StronglyTypedIdFixtures(MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/StronglyTypedIdFixtures/StronglyTypedIdFixtures.cs) andTestIdentifiers(MMCA.Common/Tests/Core/MMCA.Common.Shared.Tests/Identifiers/TestIdentifiers.cs) supply test implementations.
StronglyTypedId
MMCA.Common.Shared ·
MMCA.Common.Shared.Identifiers·MMCA.Common/Source/Core/MMCA.Common.Shared/Identifiers/StronglyTypedId.cs:19· Level 2 · class (static)
- What it is: the static toolkit behind
IStronglyTypedId<TSelf, TValue>: parsing (Parse/TryParse), reflection-based type detection (GetValueType/IsStronglyTypedId/TryDescribe), and a capability check (CanParseValues) (StronglyTypedId.cs:19-149,210). - Depends on:
StronglyTypedIdValueParser<TValue>for the actual parsing,IStronglyTypedId<TSelf, TValue>as the generic constraint on every method. BCL:ConcurrentDictionary<Type, Type?>as a process-lifetime cache (:217). - Concept introduced: none new; this is the mechanism
IStronglyTypedId<TSelf, TValue>introduces, made concrete. - Walkthrough:
ValueTypeCache(:217) is aConcurrentDictionary<Type, Type?>caching the wrapped primitive per identifier type, with anullentry for every type that is not one; the doc comment notes it is bounded by the number of CLR types the process loads, unlike a query-string keyed cache a caller could grow (:213-216).Parse<TSelf, TValue>(string, IFormatProvider?)(:231) is theIParsable.Parseleg: it null-guards (:235), then throwsFormatExceptionwhenTryParsefails (:237-240), naming the offending text and target type in the message.TryParse<TSelf, TValue>(string?, IFormatProvider?, out TSelf)(:254) fetchesStronglyTypedIdValueParser<TValue>.Instance, invokes it with the invariant culture as the default provider (:259), and on success callsTSelf.From(value)(:261) to wrap the parsed primitive. AnullInstance(an unsupportedTValue) falls through tofalse(:265-266).CanParseValues<TValue>()(:276) is a one-line capability check over the sameInstance.GetValueType(Type)(:288) andIsStronglyTypedId(Type)(:299) answer "does this type implement the contract for itself" viaResolveValueType(:326), which walkstype.GetInterfaces()for a closedIStronglyTypedId<,>whose first generic argument equals the type being asked about (:328-336): the same self-referencing guardEnumerationJsonConverterFactoryapplies elsewhere in the framework, per the doc comment (:283-284).TryDescribe(Type, out Type?, out Type?)(:309) unwrapsNullable<T>first (:316) so a caller inspecting anOrderId?property gets the same answer as one inspectingOrderId.
- Why it's built this way: ADR-115 is the governing record; ADR-085 records why this sits alongside, not instead of, the plain-alias convention.
- Where it's used:
IStronglyTypedId.cscallsParse/TryParsethrough the default interface methods;StronglyTypedIdJsonConverterFactory'sCanConvert/CreateConvertercallIsStronglyTypedId/GetValueType;StronglyTypedIdTypeConverter.ConvertFromcallsTryParse;StronglyTypedIdFilterStrategyandStronglyTypedIdRegistrycall the type-detection surface. Framework coverage isStronglyTypedIdTests(MMCA.Common/Tests/Core/MMCA.Common.Shared.Tests/Identifiers/StronglyTypedIdTests.cs, 13 call sites).
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-507). - 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-107), documented as "the exact reverse ofErrorHttpMapping.ErrorTypeToStatusCode" (:91-92). It maps eight statuses one-to-one, including429toErrorType.TooManyRequests(:105), the reverse of the forward map'sTooManyRequestsentry (MMCA.Common/Source/Presentation/MMCA.Common.API/Middleware/ErrorHttpMapping.cs:31). That forward map really does collapse three types onto 400 (Validation,InvariantandFailure, atErrorHttpMapping.cs:22,23,29), which is exactly why the reverse is lossy there and picksValidation.FromHttpStatusCode(int)(:130) is the public reverse mapping: a dictionary hit wins (:132-135), any other 4xx becomesFailure, anything else (5xx included) becomesUnexpected(:137).ParseProblemDetails(int, string?)(:155) 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" (:141-143), which is[Rubric §14, Testability]made structural. Its flow: a blank body synthesizes one error (:157-160); aJsonExceptionis caught and does the same, with the comment naming the real-world cases ("a bare challenge, an HTML error page, a proxy response",:163-171); a root that is not a JSON object likewise (:176-179). Otherwise it resolves the effective status (:181) and the fallback type (:182), then branches on theerrorsmember: an array goes toReadErrorArray(:188-190), an object toReadValidationDictionary(:192-194). Parsed errors win only when the list is non-empty (:197-200); otherwise it falls through to a synthesized error carryingdetailortitle(:203).ResolveStatus(:450) 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 (:457-461), which is what makes the parser usable against a captured payload with no response object around it.ReadErrorArray(:350) maps each object element throughReadErrorObjectand, notably, still salvages a degraded array of plain strings (:362-367).ReadErrorObject(:373) 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 (:379-384).ReadValidationDictionary(:387) handles the standard ASP.NET Core shape. The key becomesValidation.{propertyName}, or bareValidationfor an object-level rule with an empty key (:394-396), and the property name is carried separately asTarget(:397). A value may be an array of messages or a single one, and both paths funnel throughAddValidationError(:399-409), which silently ignores non-string and blank entries (:422-431).ParseErrorType(:434) is the one place an inbound string becomes an enum, and it is defensive in the right way:Enum.TryParsewithignoreCase: trueplusEnum.IsDefined(:436-437). Without the second checkTryParsewould happily accept an arbitrary numeric string and hand back an undefined enum value.ToFailureResult(:214) lifts the parsed errors into a failedResult.TryGetSynthesizedStatus(Error, out int)(:482) answers whether an error is one the reader itself synthesized for a status because the response carried no message (a bodiless or unreadable error response). It is true only when the code isHttp.followed by a number (StatusErrorCodePrefix, parsed withNumberStyles.None,:487-488) AND the message equals the reader's English default sentence for that status (:489, via the privateDefaultMessage,:505-506), so a message the server phrased itself is never mistaken for a synthesized one. A UI uses it to localize the failure by status instead of showing the English default (doc:471-477). It throwsArgumentNullExceptionon a null error (:484) and yields0for the status when it returnsfalse(:486).ReadAsync(:253) short-circuits on a 2xx toResult.Success()(:259-262) and otherwise parses the body.ReadAsync<T>(:288) is the value-returning overload: a non-success status parses errors (:300), a blank 2xx body is a failure codedEmptyResponseCode(:303-310), a body that deserializes tonullis the same failure with a different message (:315-321), and aJsonExceptionbecomesMalformedResponseCode(:323-326). 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 (:271-276).- Both
ReadAsyncoverloads carry aSuppressMessagefor analyzerRS0026("Do not add multiple public overloads with optional parameters",:252,287). The justification records them as grandfathered: released after the v1.152 public-API baseline while RS0026/RS0027 were off, so reshaping either signature now would be a breaking change. The two-overload shape is therefore frozen by the public API contract, not by preference. ReadBodyAsync(:329) buffers the content as a string and swallows anHttpRequestExceptionback tonull(:342-347), 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(:475) does case-insensitive member lookup, trying the exact name first and only then enumerating (:484-490). The doc gives the reason (:469-474): 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:366,394, 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:57,76,95,113). 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:48,72,87,126). - 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.
StronglyTypedIdConverter<TSelf, TValue>
MMCA.Common.Shared ·
MMCA.Common.Shared.Identifiers·MMCA.Common/Source/Core/MMCA.Common.Shared/Identifiers/StronglyTypedIdJsonConverterFactory.cs:38· Level 3 · class
- What it is: a private nested
JsonConverter<TSelf>, one per closed identifier type, created byStronglyTypedIdJsonConverterFactory(StronglyTypedIdJsonConverterFactory.cs:38-70). - Depends on:
System.Text.Json.Serialization.JsonConverter<T>from the BCL;StronglyTypedId.TryParsefor the dictionary-key path. - Concept introduced: none new; the JSON leg of the wrap/unwrap pattern
StronglyTypedIdMappings<TSelf, TValue>applies for manual DTO mapping. - Walkthrough:
Read(:364) deserializes the wrapped primitive and callsTSelf.From(:366-368), sodefaultstands in for anullprimitive rather than throwing.Write(:371) serializesvalue.Valuedirectly.WriteAsPropertyName(:379) andReadAsPropertyName(:387) handle the identifier-as-dictionary-key case: the wrapped primitive becomes the JSON member name viaConvert.ToString(:382-383), and reading it back throwsJsonExceptionon failure rather than silently defaulting (:390-391), documented as makingDictionary<OrderId, T>serialize exactly likeDictionary<int, T>. - Why it's built this way: ADR-115.
- Where it's used: instantiated by
StronglyTypedIdJsonConverterFactory.CreateConverterviaActivator.CreateInstance; nothing outside the defining file references it directly. Framework coverage isStronglyTypedIdSerializationTests(MMCA.Common/Tests/Core/MMCA.Common.Shared.Tests/Identifiers/StronglyTypedIdSerializationTests.cs).
StronglyTypedIdMappings<TSelf, TValue>
MMCA.Common.Shared ·
MMCA.Common.Shared.Identifiers·MMCA.Common/Source/Core/MMCA.Common.Shared/Identifiers/StronglyTypedIdMappings.cs:31· Level 3 · class (static)
- What it is: four small static conversion helpers between a wrapped identifier and its primitive,
nullable and non-nullable, meant to be called from a manual DTO mapper
(
StronglyTypedIdMappings.cs:31-54). - Depends on:
IStronglyTypedId<TSelf, TValue>as the generic constraint. - Concept introduced: none new; the manual-mapping convention (Mapperly / manual DTO mapping) applied to strongly typed identifiers, so a hand-written mapper does not repeat the wrap/unwrap logic per identifier type.
- Walkthrough:
ToValue(TSelf)(:419) andToIdentifier(TValue)(:424) are the plain non-nullable pair.ToNullableValue(TSelf?)(:429) andToNullableIdentifier(TValue?)(:434) preserve "not set" asnullon both sides of the mapping rather than defaulting to a zero value, which would be indistinguishable from a real identifier ofdefault. - Where it's used:
StronglyTypedIdMapperTests(MMCA.Common/Tests/Core/MMCA.Common.Application.Tests/Mapping/StronglyTypedIdMapperTests.cs) plus one further call site not covered by this pass.
StronglyTypedIdTypeConverter<TSelf, TValue>
MMCA.Common.Shared ·
MMCA.Common.Shared.Identifiers·MMCA.Common/Source/Core/MMCA.Common.Shared/Identifiers/StronglyTypedIdTypeConverter.cs:21· Level 3 · class
- What it is: a
System.ComponentModel.TypeConverterfor one closed identifier type, letting the BCL conversion pipeline (model binding, some UI frameworks) treat the identifier like its wrapped primitive (StronglyTypedIdTypeConverter.cs:21-69). - Depends on:
StronglyTypedId.TryParsefor the string-to-identifier path;System.ComponentModel.TypeConverterfrom the BCL. - Concept introduced: none new; the
TypeConverterleg of the wrap/unwrap patternStronglyTypedIdConverter<TSelf, TValue>applies for JSON. - Walkthrough:
CanConvertFrom/CanConvertTo(:461, 467) acceptstringandTValuein addition to whatever the base converter accepts.ConvertFrom(:473) pattern-matches:nullpasses through, aTValuewraps viaTSelf.From, a parsablestringwraps viaStronglyTypedId.TryParse, and an unparsablestringthrowsFormatExceptionnaming the text and target type (:474-482).ConvertTo(:485) unwraps aTSelfto itsTValueor to a culture-formatted string, and null-guardsdestinationTypeexplicitly rather than delegating that check to the base class (:500-502). - Where it's used: registered per identifier type by
StronglyTypedIdTypeConverters.Register. Framework coverage isStronglyTypedIdTypeConverterTests(MMCA.Common/Tests/Core/MMCA.Common.Shared.Tests/Identifiers/StronglyTypedIdTypeConverterTests.cs).
StronglyTypedIdJsonConverterFactory
MMCA.Common.Shared ·
MMCA.Common.Shared.Identifiers·MMCA.Common/Source/Core/MMCA.Common.Shared/Identifiers/StronglyTypedIdJsonConverterFactory.cs:22· Level 4 · class
- What it is: a
JsonConverterFactorythat detects any strongly typed identifier and handsSystem.Text.Jsona closedStronglyTypedIdConverter<TSelf, TValue>for it, so no per-identifier-type converter registration is needed (StronglyTypedIdJsonConverterFactory.cs:22-71). - Depends on:
StronglyTypedId.IsStronglyTypedId/GetValueTypefor detection;StronglyTypedIdConverter<TSelf, TValue>, instantiated reflectively. - Concept introduced: none new; the JSON registration point for the strongly-typed-id feature.
- Walkthrough:
CanConvert(Type)(:527) delegates straight toStronglyTypedId.IsStronglyTypedId.CreateConverter(Type, JsonSerializerOptions)(:530) resolves the wrappedTValuewithGetValueType, returnsnullwhen it is not an identifier, and otherwiseMakeGenericTypes the nested converter and instantiates it withActivator.CreateInstance(:532-538). - Why it's built this way: ADR-115.
- Where it's used: registered in
MMCA.Common.API'sDependencyInjection(MMCA.Common/Source/Presentation/MMCA.Common.API/DependencyInjection.cs) and consulted byStronglyTypedIdSchemaTransformer(MMCA.Common/Source/Presentation/MMCA.Common.API/OpenApi/StronglyTypedIdSchemaTransformer.cs). Framework coverage isStronglyTypedIdSerializationTests(MMCA.Common/Tests/Core/MMCA.Common.Shared.Tests/Identifiers/StronglyTypedIdSerializationTests.cs).
StronglyTypedIdTypeConverters
MMCA.Common.Shared ·
MMCA.Common.Shared.Identifiers·MMCA.Common/Source/Core/MMCA.Common.Shared/Identifiers/StronglyTypedIdTypeConverter.cs:82· Level 4 · class (static)
- What it is: the registration surface for
StronglyTypedIdTypeConverter<TSelf, TValue>: register one identifier type explicitly, or scan an assembly and register every identifier type it declares (StronglyTypedIdTypeConverter.cs:82-166). - Depends on:
StronglyTypedId.GetValueType/IsStronglyTypedIdfor detection;StronglyTypedIdTypeConverter<TSelf, TValue>, attached viaTypeDescriptor.AddAttributes. - Concept introduced: none new; the assembly-scan companion to the per-type converter.
- Walkthrough:
Register(Type)(:89) resolves the wrapped value type or throwsArgumentExceptionnaming the offending type (:93-96), builds the closedStronglyTypedIdTypeConverter<,>and attaches it withTypeDescriptor.AddAttributes(identifierType, new TypeConverterAttribute(converterType))(:100). For a value-type identifier it then callsTypeDescriptor.Refreshon the closedNullable<TId>(:108-111). The comment gives the failure this prevents (:102-107): MVC binds an optionalTId?query-string parameter throughNullable<TId>'s converter, andTypeDescriptorcaches thatNullableConverterwith whatever underlying converter it saw first. If anything resolved it before registration (another host in the process, a library scanning types), the cached converter still wraps the default struct converter, which cannot read a string, so MVC treatsTId?as a complex type and the request answers 500. Refreshing drops the cache so the next lookup sees the converter just registered.RegisterAll(params Assembly[])(:119) callsRegisterfor every candidateGetIdentifierTypesyields and returns the count.GetIdentifierTypes(Assembly)(:143) tolerates a partially loadable assembly: it catchesReflectionTypeLoadExceptionand falls back to the partialTypesarray (:149-156), the doc comment noting this matches the tolerance the architecture map's loadable-types helper applies elsewhere, then filters to non-generic value types satisfyingIsStronglyTypedId(:158-164). - Why it's built this way: ADR-115.
- Where it's used: called from
MMCA.Common.Infrastructure'sDependencyInjection(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs) and byStronglyTypedIdRegistryfor its own assembly-scan constructor. Framework coverage isStronglyTypedIdTypeConverterTests(MMCA.Common/Tests/Core/MMCA.Common.Shared.Tests/Identifiers/StronglyTypedIdTypeConverterTests.cs) plusStronglyTypedIdApiTests(MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Identifiers/StronglyTypedIdApiTests.cs).
StronglyTypedIdRegistry
MMCA.Common.Shared ·
MMCA.Common.Shared.Identifiers·MMCA.Common/Source/Core/MMCA.Common.Shared/Identifiers/StronglyTypedIdRegistry.cs:22· Level 5 · class
- What it is: an instance-level catalog of the strongly typed identifier types a host declares,
built once by scanning assemblies or by an explicit list, and reused wherever a host needs to
enumerate them (
StronglyTypedIdRegistry.cs:22-75). - Depends on:
StronglyTypedIdTypeConverters.GetIdentifierTypesfor the scan constructor;StronglyTypedId.IsStronglyTypedId/GetValueTypefor validation and description. - Concept introduced: none new; this is the top of the strongly-typed-id stack, the object a host DI registration actually holds.
- Walkthrough: the assembly-scan constructor (
:692) deduplicates the input assemblies, flattensGetIdentifierTypesacross them, deduplicates the result, and orders it byFullName(:696-703) for a stable enumeration order across runs. The explicit-list constructor (:712) validates every entry withIsStronglyTypedIdand throwsArgumentExceptionnaming the first offender (:718-724) rather than silently accepting a non-identifier type.IdentifierTypes(:730) exposes the ordered result.Describe()(:737) pairs each identifier type with its wrapped primitive viaGetValueType, using the null-forgiving!because every entry already passed validation. - Why it's built this way: ADR-115.
- Where it's used: constructed and consulted by
MMCA.Common.Infrastructure'sDependencyInjection(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.cs) and byStronglyTypedIdModelConfiguration(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/Conventions/StronglyTypedIdModelConfiguration.cs) andApplicationDbContext(MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/ApplicationDbContext.cs) for EF value-converter wiring. Framework coverage isStronglyTypedIdTests(MMCA.Common/Tests/Core/MMCA.Common.Shared.Tests/Identifiers/StronglyTypedIdTests.cs).
AcceptLegalTermsRequest
MMCA.Common.Shared ·
MMCA.Common.Shared.Legal·MMCA.Common/Source/Core/MMCA.Common.Shared/Legal/AcceptLegalTermsRequest.cs:12· Level 0 · record struct
- What it is: the POST body for recording a terms acceptance, a one-field
readonly record structcarrying theVersionstring the user is accepting (MMCA.Common/Source/Core/MMCA.Common.Shared/Legal/AcceptLegalTermsRequest.cs:12). - Depends on: nothing first-party; BCL
stringonly. - Concept introduced, the client names the version it saw. The request does not say "accept
whatever is current": it carries the version the user was shown, so the server can reject a stale
acceptance (see
LegalAcceptanceErrorCodes).[Rubric §30, Compliance / Privacy / Data Governance]assesses whether consent is bound to the exact text consented to. - Walkthrough: a positional record struct, so there is no behavior, only the
Versionparameter (AcceptLegalTermsRequest.cs:12). Being a value type with no validation attributes, the version check happens server-side against the configured current version. - Where it's used: bound with
[FromBody]byLegalAcceptanceControllerBase(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Legal/LegalAcceptanceControllerBase.cs:100) and constructed byLegalAcceptanceUIServicewhen the gate posts an acceptance (MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Legal/LegalAcceptanceUIService.cs:54). Framework coverage isLegalAcceptanceControllerBaseTests(MMCA.Common/Tests/Presentation/MMCA.Common.API.Tests/Controllers/Legal/LegalAcceptanceControllerBaseTests.cs).
LegalAcceptanceDTO
MMCA.Common.Shared ·
MMCA.Common.Shared.Legal·MMCA.Common/Source/Core/MMCA.Common.Shared/Legal/LegalAcceptanceDTO.cs:18· Level 0 · record
- What it is: a user's terms-acceptance standing:
CurrentVersion,AcceptedVersion,AcceptedOnand the derivedIsCurrentflag (MMCA.Common/Source/Core/MMCA.Common.Shared/Legal/LegalAcceptanceDTO.cs:18-54). - Depends on: nothing first-party; BCL only.
- Concept introduced, standing is computed once, in one place.
[Rubric §30, Compliance / Privacy / Data Governance]assesses whether the "is this user up to date" decision is consistent across the API and the UI. All four properties areinit-only andIsCurrentis never set by callers: the static factoryEvaluatederives it, so no caller can disagree about what "current" means. - Walkthrough:
Evaluate(currentVersion, acceptedVersion, acceptedOn)(LegalAcceptanceDTO.cs:42-53) trims the configured version and treats null or whitespace as "none configured" (:44).IsCurrentis true when no version is configured, or when the accepted version equals the current one by ordinal comparison (:51). With nothing configured every user is current, so a host that sets no terms version never gates anyone. - Where it's used: built by
LegalAcceptancePolicy(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Legal/LegalAcceptancePolicy.cs:51), by the controller's empty-standing fallback (MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Legal/LegalAcceptanceControllerBase.cs:68), and returned throughILegalAcceptanceService(ILegalAcceptanceService.cs:18). The UITermsAcceptanceGateconsumes it to decide whether to show the acceptance dialog. Unit coverage isLegalAcceptanceDTOTests(MMCA.Common/Tests/Core/MMCA.Common.Shared.Tests/Legal/LegalAcceptanceDTOTests.cs).
LegalAcceptanceErrorCodes
MMCA.Common.Shared ·
MMCA.Common.Shared.Legal·MMCA.Common/Source/Core/MMCA.Common.Shared/Legal/LegalAcceptanceErrorCodes.cs:7· Level 0 · class (static)
- What it is: the wire-visible error code constants for the legal-acceptance flow; currently one,
VersionNotCurrent = "Legal.VersionNotCurrent"(MMCA.Common/Source/Core/MMCA.Common.Shared/Legal/LegalAcceptanceErrorCodes.cs:13). - Depends on: nothing; constants only.
- Concept introduced, error codes live in Shared so the UI can match them. The server builds the
Errorin the Application layer and the Blazor client must recognise it without referencing Application, the same placement ruleAuthErrorCodesfollows.[Rubric §15, Best Practices & Code Quality]covers the single source of truth for a literal both ends compare. - Walkthrough: the doc comment states the meaning: an acceptance named a version other than the
configured current one, including any acceptance while no version is configured, and a client should
re-read the standing and show the current terms (
LegalAcceptanceErrorCodes.cs:9-12). - Where it's used: aliased as
VersionNotCurrentCodebyLegalAcceptanceErrors(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Legal/LegalAcceptanceErrors.cs:17), and matched byTermsAcceptanceGateto trigger a standing refresh (MMCA.Common/Source/Presentation/MMCA.Common.UI/Components/Legal/TermsAcceptanceGate.razor.cs:207).
LegalAcceptanceRoutes
MMCA.Common.Shared ·
MMCA.Common.Shared.Legal·MMCA.Common/Source/Core/MMCA.Common.Shared/Legal/LegalAcceptanceRoutes.cs:7· Level 0 · class (static)
- What it is: the route literals for the legal-acceptance endpoint:
Action = "me/legal-acceptance"andPath = "Users/" + Action(MMCA.Common/Source/Core/MMCA.Common.Shared/Legal/LegalAcceptanceRoutes.cs:7-17). - Depends on: nothing; constants only.
- Concept introduced, one route literal for server and client. The API attribute and the UI HTTP
call must agree on the URL, so both read the same constants from Shared, as
MessageHeadersdoes for broker headers.[Rubric §15, Best Practices & Code Quality]covers the single-source-of-truth angle. - Walkthrough:
Actionis the template relative to a controller routed atUsers(the data-export precedent):GETreads the caller's standing,POSTrecords an acceptance (LegalAcceptanceRoutes.cs:9-13).Pathis the full relative path the client calls (:16). - Where it's used:
[HttpGet]/[HttpPost]onLegalAcceptanceControllerBase(MMCA.Common/Source/Presentation/MMCA.Common.API/Controllers/Legal/LegalAcceptanceControllerBase.cs:59,93) and the two HTTP calls inLegalAcceptanceUIService(MMCA.Common/Source/Presentation/MMCA.Common.UI/Services/Legal/LegalAcceptanceUIService.cs:35,53).
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.
MessageHeaders
MMCA.Common.Shared ·
MMCA.Common.Shared.Messaging·MMCA.Common/Source/Core/MMCA.Common.Shared/Messaging/MessageHeaders.cs:22· Level 0 · class (static)
- What it is: four
const stringmessage-broker header names carried on every outbound message: the tenant the publishing scope was resolved to, the publishing user's identifier, that user's roles as a comma-separated list, and the correlation id of the interaction that produced the message (MessageHeaders.cs:25-34). - Depends on: nothing; BCL-free constants only.
- Concept introduced, the cross-layer wire literal for a broker envelope. Same placement rule
ConcurrencyETagandIdempotencyHeadersfollow for an HTTP header, applied here to a message-broker header: the publisher and the consumer are separate assemblies that must agree on a literal, so it lives once in Shared.[Rubric §10, Messaging & Integration]assesses whether cross-service metadata (tenant, actor, correlation) travels explicitly on the envelope rather than being inferred on the consuming side;[Rubric §15, Best Practices & Code Quality]covers the single-source-of-truth angle. - Walkthrough: a static class with four const fields:
TenantId = "MMCA-Tenant-Id"(:28),UserId = "MMCA-User-Id"(:31),UserRoles = "MMCA-User-Roles"(:34), andCorrelationId = "MMCA-Correlation-Id"(:37). Each doc comment states when the header is absent:TenantIdfor a tenant-less publish,UserIdfor work raised by the system rather than a user,UserRoleswhen the actor holds none. - Why it's built this way: ADR-021 and ADR-073 are the governing records; the multi-tenancy model relies on the tenant header traveling with every message rather than being re-derived on the consuming side.
- Where it's used:
BrokerMessageBussets these headers on publish (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/BrokerMessageBus.cs), andConsumerOriginRestorereads them back to rehydrate the tenant/actor context for a handler (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Messaging/Consumers/ConsumerOriginRestore.cs). Framework coverage isBrokerMessageBusTests(MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Messaging/BrokerMessageBusTests.cs) andIntegrationEventConsumerContextTests(MMCA.Common/Tests/Core/MMCA.Common.Infrastructure.Tests/Messaging/Consumers/IntegrationEventConsumerContextTests.cs).
PrivacyFeatures
MMCA.Common.Shared ·
MMCA.Common.Shared.Privacy·MMCA.Common/Source/Core/MMCA.Common.Shared/Privacy/PrivacyFeatures.cs:8· Level 1 · 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:12). - Depends on:
FeatureFlagAttributeandFeatureFlagLifetime(bothMMCA.Common.Shared.FeatureFlags), which the const now carries. - Concept introduced, the feature flag as a shared, self-declaring 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. What the const also now declares is its own lifecycle:[FeatureFlag(FeatureFlagLifetime.Permanent, Owner = "MMCA.Common")](PrivacyFeatures.cs:11) marks it as a flag that is never expected to be removed, which is the same governance annotationNotificationFeaturescarries (MMCA.Common/Source/Core/MMCA.Common.Shared/Notifications/NotificationFeatures.cs:11). - Walkthrough: a
static classcontaining a single[FeatureFlag(FeatureFlagLifetime.Permanent, Owner = "MMCA.Common")] public const string DataExport = "Privacy.DataExport";(PrivacyFeatures.cs:8-13). 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. The attribute takes the lifetime as a positional argument andOwneras a named one; aPermanentflag must not name aRemoveBydate, unlike aTemporaryone (MMCA.Common/Source/Core/MMCA.Common.Shared/FeatureFlags/FeatureFlagAttribute.cs:10-11). - 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). The[FeatureFlag]annotation is the later, codebase-wide discipline that makes every flag self-reporting:FeatureFlagLifecycleTestsBasefails the build on a flag that carries no declaration or on aTemporaryone past itsRemoveBy(MMCA.Common/CLAUDE.md, "FeatureGate" bullet), andFeatureFlagRegistryreports the same inventory at runtime. - 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:60) with the rationale in the same file's remarks (DataExportControllerBase.cs:54-55). 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). The[FeatureFlag]declaration is read byFeatureFlagLifecycleTestsandFeatureFlagRegistryTests(MMCA.Common/Tests/Architecture/MMCA.Common.Architecture.Tests/Governance/FeatureFlagLifecycleTests.cs,MMCA.Common/Tests/Core/MMCA.Common.Shared.Tests/FeatureFlags/FeatureFlagRegistryTests.cs). - 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.
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:103-113).[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:80) and derives the download file name from the package's ownGeneratedOnso the file name and the document can never disagree (DataExportControllerBase.cs:113,128-138). 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).
ConfirmEmailRequestValidator
MMCA.Common.Application ·
MMCA.Common.Application.Auth.Validation·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/EmailConfirmationRequestValidators.cs:25· Level 1 · class
- What it is: the validator for redeeming an email-confirmation link:
Emailmust be non-empty and a valid address,Tokenmust be non-empty (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/EmailConfirmationRequestValidators.cs:30-35). - Depends on:
ConfirmEmailRequest;FluentValidation'sAbstractValidator<T>. - Concept: the same "validation that deliberately stops short" posture
ForgotPasswordRequestValidatorintroduces: shape only, no lookup of whether the token is live or already consumed.[Rubric §11, Security]: token validity is a database read guarded by hashing and expiry (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Interfaces/IEmailConfirmationTokenService.csusage in the consuming handler), not a shape a validator can decide, so putting that check here would either duplicate the lookup or race it. - Walkthrough: a block-bodied constructor with two independent
RuleForchains (EmailConfirmationRequestValidators.cs:30-35), each carrying an explicitWithMessage, sharing the file withSendEmailConfirmationRequestValidator. - Why it's built this way: email confirmation is one of the opt-in identity completions
(ADR-116): a host calls
AddEmailConfirmation(config)to register the feature, and sign-in gating only kicks in whenRequireConfirmedEmailis configured and theUserimplementsIEmailConfirmableUser(MMCA.Common/CLAUDE.md, "Identity completions" section). - Where it's used: registered by the assembly scan
(
MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:48) and reached throughCommandRequestValidator<TCommand, TRequest>for any command implementingICommandWithRequest<ConfirmEmailRequest>, the constraintConfirmEmailHandlerBase<TUser, TCommand>declares (MMCA.Common/Source/Core/MMCA.Common.Application/Users/UseCases/EmailConfirmation/ConfirmEmailHandlerBase.cs:38).MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/Cqrs/CommandValidatorCoverageTests.csasserts every command that carries a request has a validator registered, which is the only reference to this type outside its defining assembly.
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:48) 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:45-47). 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 atMMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.ModuleScanning.cs:119-135.
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:48(which names this class in its comment,DependencyInjection.cs:45), then injected asIValidator<LoginRequest>intoAuthenticationValidators, the parameter object that bundles the three auth validators (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationValidators.cs:25,31), which is in turn whatAuthenticationServiceBase<TUser>consumes. - Caveats / not-in-source:
AuthenticationValidatorsalso requires anIValidator<RegisterRequest>(AuthenticationValidators.cs:26,34), 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:48, named in the comment at:47) and injected asIValidator<RefreshTokenRequest>intoAuthenticationValidators(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/AuthenticationValidators.cs:19,28).
SendEmailConfirmationRequestValidator
MMCA.Common.Application ·
MMCA.Common.Application.Auth.Validation·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/EmailConfirmationRequestValidators.cs:11· Level 1 · class
- What it is: the validator for requesting a confirmation email:
Emailmust be non-empty and a valid address, nothing else (MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/EmailConfirmationRequestValidators.cs:14-16). - Depends on:
SendEmailConfirmationRequest;FluentValidation'sAbstractValidator<T>. - Concept: the same shape-only posture as
ForgotPasswordRequestValidator: the field is checked for well-formedness, not for whether it belongs to an account.[Rubric §11, Security]applies for the same reason a resend-confirmation endpoint is a candidate for the same account-enumeration trap a forgot-password endpoint is. - Walkthrough: an expression-bodied constructor with a single
RuleFor(x => x.Email).NotEmpty().EmailAddress()chain (EmailConfirmationRequestValidators.cs:14-16), sharing the file withConfirmEmailRequestValidator. - Why it's built this way: email confirmation is one of the identity completions
ADR-116 added, all opt-in
through
AddEmailConfirmation(config). - Where it's used: registered by the assembly scan
(
MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:48).MMCA.ADC/Tests/Architecture/MMCA.ADC.Architecture.Tests/Cqrs/CommandValidatorCoverageTests.csis the only reference to this type outside its defining assembly, asserting the command that carries this request has a validator registered.
TwoFactorCodeRequestValidator
MMCA.Common.Application ·
MMCA.Common.Application.Auth.Validation·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/TwoFactorCodeRequestValidator.cs:16· Level 1 · class
- What it is: the validator for a submitted two-factor code:
Codemust be non-empty and no longer thanMaxCodeLength(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/TwoFactorCodeRequestValidator.cs:22-24). - Depends on:
TwoFactorCodeRequest;FluentValidation'sAbstractValidator<T>. - Concept: presence and a length ceiling only, the shape-only posture
ForgotPasswordRequestValidatorintroduced, applied here to bound input size rather than to avoid an enumeration oracle.[Rubric §11, Security]: whether a TOTP code or a recovery code was presented is not decided here; both shapes are short strings, so the validator can only reject the obviously wrong length, and the doc comment names 64 as the ceiling (TwoFactorCodeRequestValidator.cs:17). - Walkthrough:
public const int MaxCodeLength = 64;(:17) followed by an expression-bodied constructor chainingNotEmpty().MaximumLength(MaxCodeLength)onCode(:22-24). The constant is public so a caller (or a test) can reference the same ceiling rather than re-guessing it. - Why it's built this way: two-factor authentication is one of the opt-in identity completions
(ADR-116); the actual
decision between a TOTP match and a recovery-code match happens downstream in
TwoFactorAuthenticator, not in this validator. - Where it's used: registered by the assembly scan
(
MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:48). - Caveats / not-in-source: no first-party or test reference to this type appears outside its defining file; the assembly scan is the only wiring visible in source.
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:191-202): 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:175-182), 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:48) 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:50). 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).
TotpTwoFactorService
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Auth.TwoFactor·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/TwoFactor/TotpTwoFactorService.cs:35· Level 2 · class
- What it is: the RFC 6238 TOTP implementation of
ITwoFactorService: secret generation, provisioning-URI construction, code verification, and recovery-code generation/hashing/matching (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/TwoFactor/TotpTwoFactorService.cs:35-159). - Depends on:
ITwoFactorService(the contract it implements);TwoFactorSettings(MMCA.Common.Shared) injected asIOptions<TwoFactorSettings>;RecoveryCodeSet(MMCA.Common.Shared) as its return shape;OtpDotNet(Base32Encoding,KeyGeneration,Totp,VerificationWindow,OtpHashMode, NuGet);System.Security.Cryptography(RandomNumberGenerator,SHA256,CryptographicOperations.FixedTimeEquals). - Concept introduced, constant-time comparison for a secret the caller does not control the timing
of.
[Rubric §11, Security]assesses whether the codebase avoids timing side channels around secret comparison.TryMatchRecoveryCodenever exits its scan early on a match: the loop "runs to the end so the answer takes the same time whichever code in the list was presented" (TotpTwoFactorService.cs:140-141), and each comparison itself goes throughCryptographicOperations.FixedTimeEqualsrather than==orSequenceEqual(:142). A byte-for-byte comparison that stops at the first differing byte leaks how many leading bytes an attacker guessed correctly across repeated attempts; a fixed-time comparison over a full linear scan removes that channel entirely. - Walkthrough:
internal sealed classwith a primary constructor takingIOptions<TwoFactorSettings> settings(:35), caching.Valueonce (:37).GenerateSecret()returns a Base32-encoded random key sized by_settings.SecretByteLength(:40-41).BuildProvisioningUrirejects a blank secret or account name, URL-escapes the issuer and account name, and formats the standardotpauth://URI a QR code encodes: the algorithm is fixed atSHA1, while digit count and period are read from settings (:44-55).VerifyCodecomes in two overloads. The two-argument form is a one-line forward to the three-argument form, discarding the step (:58). The three-argument form reports the matched TOTP time step throughout long matchedStep, initialized to0so every earlyfalseleaves it defined (:61-63). It returnsfalsefor a blank secret or code (:64-67), then decodes the stored Base32 secret, catchingArgumentExceptionand returningfalserather than throwing when the stored value is not valid Base32: "a stored secret that is not Base32 can never mint a code, so it verifies nothing. That is a data fault, not a caller fault" (:74-80). It then builds aTotpwith the configured period, SHA-1 hash mode, and digit count (:82), and checks the normalized code against a verification window of_settings.VerificationWindowStepssteps on each side of now, writing the step the code matched intomatchedStep(:83-87). The window tolerates clock drift between the server and the user's authenticator app; the reported step is what letsTwoFactorAuthenticatorrefuse a replay of the same code inside that window.GenerateRecoveryCodesproduces_settings.RecoveryCodeCountcodes, each a Base32-encoded random byte string of_settings.RecoveryCodeByteLengthbytes with the=padding trimmed, alongside their SHA-256 hashes computed byHashRecoveryCode(:91-109). Base32 rather than Base64: "the alphabet has no case ambiguity and no characters a user has to guess at while copying a code off paper" (:98-99). Only the hashes and the caller-facing codes are returned as aRecoveryCodeSet(:108); nothing persists the plaintext codes here.HashRecoveryCodenormalizes the code (keeps letters and digits, upper-cases) then SHA-256-hashes it to an upper-case hex string (:112-117).TryMatchRecoveryCodenormalizes and hashes the presented code once, skipsnullstored entries, then fixed-time-compares it against every stored hash, returning the matched hash (not the code) through anoutparameter (:120-149).NormalizeCodeis the shared helper both verification paths route through: it strips whitespace and separators a user types or a password manager inserts and upper-cases what remains, "so a code is matched the way it was generated" (:151-158).
- Why it's built this way: two-factor authentication is opt-in
(ADR-116), registered by
AddTwoFactorAuthentication(config);MMCA.Common/CLAUDE.mdnotes Otp.NET is scoped to Infrastructure only, which is why this type, rather than anything in Application, is what actually calls into the library. - Where it's used: registered in DI
(
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Auth.cs:48, aTryAddSingleton) as theITwoFactorServiceimplementation behindAddTwoFactorAuthentication(config)(DependencyInjection.Auth.cs:40), and consumed byTwoFactorAuthenticatorfor both TOTP and recovery-code verification. - Caveats / not-in-source:
internal sealed, so it is reached only through theITwoFactorServiceabstraction; a consumer cannot construct or type-check against this class directly.
SetRolePermissionsRequestValidator
MMCA.Common.Application ·
MMCA.Common.Application.Auth.Validation·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/AdministrationRequestValidators.cs:30· Level 4 · class
- What it is: the validator for replacing a role's stored permission grants:
Permissionsmust be non-null, and each entry must be non-empty and withinPermissionGrant.PermissionMaxLength(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/AdministrationRequestValidators.cs:34-40). - Depends on:
SetRolePermissionsRequest;PermissionGrant(forPermissionMaxLength);FluentValidation'sAbstractValidator<T>. - Concept: shape validation for a role-administration write, the same posture the request validators
earlier in this group establish, but the field it guards is a collection rather than a scalar.
[Rubric §11, Security]: an emptyPermissionslist is deliberately legal, and the doc comment on the rule states why: it "is how an operator undoes every grant. It never removes what the host compiled in" (AdministrationRequestValidators.cs:37-38). What this validator refuses isnull, a malformed request, not an empty intent; and it bounds each entry's length so a caller cannot smuggle an oversized string into a permission name. - Walkthrough: a block-bodied constructor with
RuleFor(x => x.Permissions).NotNull()(:39) followed byRuleForEach(x => x.Permissions).NotEmpty().MaximumLength(PermissionGrant.PermissionMaxLength)(:41-44), each stage carrying an explicitWithMessage. It shares its file withSetUserRolesRequestValidator, the sibling that guards role assignment rather than permission grants. - Why it's built this way: what a stored permission grant is layered over, and why the compiled
AdministrationPermissions.ManageRolespermission can never itself become a stored grant, isStoredPermissionRoleAdministrationService's concern, not this validator's; this type only decides whether the shape of a replacement list is acceptable. - Where it's used: registered by the assembly scan
(
MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:48). - Caveats / not-in-source: no first-party or test reference to this type appears outside its defining file.
SetUserRolesRequestValidator
MMCA.Common.Application ·
MMCA.Common.Application.Auth.Validation·MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/AdministrationRequestValidators.cs:11· Level 4 · class
- What it is: the validator for replacing a user's role list:
Rolesmust be non-null, and each entry must be non-empty and withinPermissionGrant.RoleMaxLength(MMCA.Common/Source/Core/MMCA.Common.Application/Auth/Validation/AdministrationRequestValidators.cs:15-21). - Depends on:
SetUserRolesRequest;PermissionGrant(forRoleMaxLength);FluentValidation'sAbstractValidator<T>. - Concept: the mirror of
SetRolePermissionsRequestValidatorone level up in the same file: an empty list is a legitimate intent (stripping every role from an account) whilenullis a malformed body. The doc comment draws the same line: "What is rejected is a null list, which is a malformed body rather than an intent" (AdministrationRequestValidators.cs:18-19). - Walkthrough:
RuleFor(x => x.Roles).NotNull()(:20) followed byRuleForEach(x => x.Roles).NotEmpty().MaximumLength(PermissionGrant.RoleMaxLength)(:22-25), each with an explicitWithMessage. - Why it's built this way: the same administration surface
SetRolePermissionsRequestValidatorguards; both validators keep the shape check separate from the business rule (which roles/permissions actually exist), which is decided downstream by the role-administration service. - Where it's used: registered by the assembly scan
(
MMCA.Common/Source/Core/MMCA.Common.Application/DependencyInjection.cs:48). - Caveats / not-in-source: no first-party or test reference to this type appears outside its defining file.
TwoFactorAuthenticator
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Auth.TwoFactor·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/TwoFactor/TwoFactorAuthenticator.cs:36· Level 4 · class
- What it is: the
ITwoFactorAuthenticatorimplementation that turns a raw code plus a user's stored two-factor state into one of four challenge outcomes (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/TwoFactor/TwoFactorAuthenticator.cs:36-116). - Depends on:
ITwoFactorService(code verification and recovery-code matching);ITwoFactorStore(loading state and consuming a recovery code);ICacheService(remembering the last accepted TOTP time step per account);TwoFactorSettingsviaIOptions<T>(period and window size);TwoFactorErrors;TwoFactorOutcome; the framework'sResult<T>; theUserIdentifierTypealias. - Concept introduced, a challenge is answered with an outcome enum, not a bare boolean.
[Rubric §1, SOLID]and[Rubric §11, Security]both bear on the shape ofChallengeAsync's return:Result<TwoFactorOutcome>distinguishes "not enrolled" (a success carryingTwoFactorOutcome.NotEnrolled, not a failure) from a missing code, an invalid code, and the two ways a valid one can be accepted,VerifiedTotpversusVerifiedRecoveryCode. Collapsing those five states into one boolean would force the caller to re-derive which one happened from side effects, and a login path that cannot tell "not enrolled" from "denied" cannot decide whether to prompt for enrollment or reject the sign-in. - Walkthrough:
internal sealed classtakingITwoFactorService,ITwoFactorStore,ICacheService, andIOptions<TwoFactorSettings>as primary constructor parameters (:36-40), implementingChallengeAsync(userId, code, cancellationToken)(:43-86).- Loads the user's
statefrom the store first (:48). If the state is missing orIsTwoFactorEnabledis false, the challenge succeeds trivially withNotEnrolled: "an account that never enrolled, and an account whose row vanished between the password check and here, are both 'nothing to challenge'" (:50-56). - An enrolled account with no presented code fails with
TwoFactorErrors.TwoFactorRequired(:58-62). - The code is checked first against the stored TOTP secret via the
out long matchedStepoverload ofITwoFactorService.VerifyCode; a match is not accepted outright but handed toAcceptTimeStepOnceAsyncwith the step it matched (:64-68). - Failing that, it is checked against the stored recovery-code hashes via
ITwoFactorService.TryMatchRecoveryCode; no match fails withTwoFactorErrors.TwoFactorInvalid(:70-75). - A recovery-code match is then consumed, not just verified:
store.ConsumeRecoveryCodeAsyncpersists the redemption, and if that persistence fails, the whole challenge is answered as a failure rather than a success: "letting the sign-in through would hand out a code that is still live" (:77-85). Only a successfully persisted consumption returnsVerifiedRecoveryCode. - The private
AcceptTimeStepOnceAsyncmakes a TOTP code single use (:96-114). It keys the account astwofactor:laststep:{userId}(:101) and reads the last accepted step withGetFromSharedStoreAsync, deliberately bypassing a local copy: "a step accepted on another replica must be seen here, or a replayed code would pass against a stale local copy inside its window" (:103-104). A matched step not newer than the stored one fails withTwoFactorErrors.TwoFactorInvalid(:106-109). Otherwise the step is written back with a lifetime ofPeriodSeconds * (2 * VerificationWindowSteps + 2)seconds, long enough to outlast every step the verification window could still accept, and the challenge returnsVerifiedTotp(:111-114).
- Loads the user's
- Why it's built this way: this is the runtime half of the opt-in two-factor feature
(ADR-116). Both kinds of
code are made one-time inside the challenge rather than left to the caller. A recovery code's
one-time-use property is only real if consumption and the outcome it produces are atomic, which is why
the failure path is checked before the outcome is returned rather than after. A TOTP code would
otherwise stay valid for its whole verification window, so a code seen over someone's shoulder could be
replayed; remembering the last accepted step closes that window
(
TwoFactorAuthenticator.cstype remarks,:25-29). - Where it's used: registered in DI
(
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Auth.cs:51, aTryAddScoped) as theITwoFactorAuthenticatorbehindAddTwoFactorAuthentication(config); the resolved instance is passed toAuthenticationServiceBase<TUser>, which answersAuthentication.TwoFactorRequiredor mints themfaclaim depending on the outcome (MMCA.Common/CLAUDE.md, "Identity completions" section).
StoredPermissionRoleAdministrationService
MMCA.Common.Infrastructure ·
MMCA.Common.Infrastructure.Auth.Administration·MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/Administration/StoredPermissionRoleAdministrationService.cs:45· Level 5 · class
- What it is: the
IRoleAdministrationServiceimplementation that lets an operator list roles, read or replace a role's stored permissions, and view the compiled permission catalog, layered over the code-compiled permission registry (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Auth/Administration/StoredPermissionRoleAdministrationService.cs:45-257). - Depends on:
IPermissionRegistry(the compiled grants);IPermissionCatalog(the closed list of declared permissions);IPermissionGrantStore(persisted grants);IPermissionGrantCacheandIPermissionGrantCacheInvalidator;PermissionGrantSettings(KnownRoles) viaIOptions<T>;AdministrationPermissions(ManageRoles); the framework'sResult<T>andErrorfactories. - Concept introduced, a layered grant surface that refuses to let stored data override the permission
that guards it.
[Rubric §11, Security]assesses whether an authorization surface can be used to escalate its own access, and[Rubric §4, DDD]/[Rubric §1, SOLID]the separation between the compiled and stored halves of a permission set.SetStoredPermissionsAsyncrefuses to grantAdministrationPermissions.ManageRolesfrom a stored row, and the error message states the two-sided reason: granting it from a data row would make access to role administration itself a matter of data, while deleting the row "would lock every operator out of the screen that could restore it" (StoredPermissionRoleAdministrationService.cs:142-149). That permission is only ever compiled in, through a host's own permission registry. - Walkthrough: constructed with the registry, catalog, store, cache, invalidator, and settings as
primary constructor parameters (
:45-51).ListRolesAsyncgroups all stored grants by role, case-insensitively (:59-64), unions the result with the compiled and configured role universes via the privateRoleUniversehelper (:66), and returns oneRolePermissionsResponseper role pairing its compiled permissions (CompiledPermissions) with its stored ones (:68-76).GetCatalogAsyncreturns every known role alongside the full compiledcatalog.Permissionsdeliberately, not whatever happens to be stored: "widening it with whatever happens to be stored would let one typo legitimize itself" (:87-89).GetRoleAsyncreturns not found when the privateIsKnownRolerule fails (:105-108), so "a role the host has never named ... does not exist as far as this surface is concerned" rather than being reported as an empty, plausible-looking role (rule documented at:223-226).- The private
IsKnownRolehelper is the single existence rule: a role exists when the registry compiles at least one permission for it, or when it belongs to theRoleUniverse(catalog roles, configuredKnownRoles, or a role that already carries a stored grant) (:219-235). SetStoredPermissionsAsyncapplies that same existence rule first, before anything is validated or written, reading the role's current stored grants once (:127-134): "a set must not be the route by which a typo becomes a role", whether with an empty list (answering success for a role that does not exist) or a non-empty one (creating it). It then trims and de-duplicates the desired set (:136-138), rejectsManageRolesas above, rejects any permission absent fromcatalog.Permissionswith the unknown names listed in the error (:151-163), then diffs the desired set against the current one read earlier (:165): grants what is newly desired, revokes what was dropped, short-circuiting on the first store failure either way (:172-191). The two loops sit in atrywhosefinallyinvalidates the role's permission cache on every exit, failure and exception included (:170-196): each grant and revoke "commits on its own, so a failure or a throw part-way through leaves earlier rows already written", and invalidating only on success would leave the process "serving the pre-edit snapshot until the next refresh" (:167-169). On success it returns the freshRolePermissionsResponse(:198-200).- The private
RoleUniversehelper unions the compiled catalog's roles, the configuredKnownRoles, and whatever roles the store's own rows name, case-insensitively and sorted (:210-217). - The private
CompiledPermissionshelper reads "through the SAME registry the authorization path uses" and subtracts what the cache already reports, so the two lists a role response carries stay disjoint (:243-256).
- Why it's built this way:
ADR-116 documents
AddStoredPermissionGrants(config)as the opt-in that layers aPermissionGranttable over the compiled registry;MMCA.Common/CLAUDE.md's Authorization section describes the same union-only layering throughLayeredPermissionRegistry"without changing the decorators' contract", the principle this service'sManageRolesrefusal exists to protect. - Where it's used: registered in DI
(
MMCA.Common/Source/Core/MMCA.Common.Infrastructure/DependencyInjection.Auth.cs:159, aTryAddScoped) behindAddStoredPermissionGrants(config)(DependencyInjection.Auth.cs:139) as theIRoleAdministrationServiceimplementation; consumed throughRolesAdminControllerBase, which is itself gated onAdministrationPermissions.ManageRoles(MMCA.Common/CLAUDE.md, "Identity completions" section). - Caveats / not-in-source:
internal sealed, so it is reached only through theIRoleAdministrationServiceabstraction.
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:116), 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:116). 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.Notifications.cs:67), in the same block that swaps the null notification implementations for the SignalR-backed ones (:64-66). 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/Context/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:307), 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:307). The highest-traffic consumer isDbContextFactory, which takes it as a constructor dependency (MMCA.Common/Source/Core/MMCA.Common.Infrastructure/Persistence/DbContexts/Factory/DbContextFactory.cs:53,: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:27). 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.