LoginFlow is the deep authentication module. It is not three lines that fetch a user, compare a password, and issue JWT. It is a state machine with several business outcomes, and clients and alternative authentication entries must handle them explicitly.
1. State-machine overview
Login.Command only combines request, tenant, platform, IP, and User-Agent into LoginInput and delegates to ILoginFlow. Architecture tests pin that boundary so the handler cannot become a second login implementation.
2. HTTP input and client platform
POST /api/auth/login is anonymous and uses authPolicy plus the standard command timeout. Effective tenant comes from ICurrentTenant; IP and User-Agent come from the HTTP context.
Password client encryption (required)
- Call
GET /api/auth/cipher-keyfor SPKI public key,keyId,serverTimestamp, andmaxClockSkewSeconds. - Browser builds
nonce|timestamp|passwordand encrypts with RSA-OAEP-SHA256 (Web Crypto). - Login body sends only
encryptedPassword+cipherKeyId— never plaintextpassword. - Server resolves the private key for that keyId, decrypts, checks clock skew and one-time Nonce, then runs BCrypt.
Server keys:
| Item | Behavior |
|---|---|
| Generation | Distributed mode creates a shared first key when the cache is absent; only memory fallback creates one at each API process start |
| keyId | {yyyyMMddHHmmss}-{6hex} |
| Rotation | Default RotationHours=24; effective range 1–720 hours (cap = 30 days) — values outside are silently clamped, process still starts. Hosted service sleeps then rotates. Previous-key grace defaults to 10 minutes (at least 1) |
| Ring | At most current + previous can decrypt |
| Multi-instance | With ICacheStore plus the private-key material protector, current + previous are shared; instances must share a Data Protection key ring. Missing either dependency falls back in-process with a Warning |
| Nonce | ICacheStore area identity:cipher:nonce; in-process fallback + Warning without Redis |
| Errors | KeyExpired / DecryptFailed / ClockSkewExceeded / ReplayDetected / EncryptedPasswordRequired / PayloadInvalid |
Platform SDK encrypts login, change/reset password, activation, create-user, and other password-bearing paths automatically and retries once on Identity.Cipher.KeyExpired.
{ "userName": "alice@example.com", "encryptedPassword": "Base64(RSA-OAEP(nonce|serverTs|password))", "cipherKeyId": "20260811120000-a1b2c3", "rememberMe": false, "deviceId": "alice-browser-01", "captchaChallengeId": null, "captchaAnswer": null}Server config Identity:Password:Cipher
Bound by PasswordCipherOptions. Defaults apply if the section is omitted. Full lifecycle, validation vs clamp, and ops notes: Identity configuration §2.1.
Key (under Identity:Password:Cipher) | Default | Range / out-of-range | Meaning |
|---|---|---|---|
KeySize | 2048 | 2048–4096; startup validation fails process | RSA bits |
RotationHours | 24 | 1–720 (cap 720h = 30 days); no startup validation — Math.Clamp only | Rotation interval (hours). Values above 720 still start the host but rotate every 30 days |
GracePeriodMinutes | 10 | Runtime min 1; no startup upper bound | Previous-key decrypt grace (minutes); recommend 5–30 and much less than rotation |
MaxClockSkewSeconds | 120 | 30–600; startup validation | Timestamp skew cap (seconds); also returned on cipher-key |
NonceTtlSeconds | 120 | 30–600; startup validation + claim-time clamp | Nonce cache TTL (seconds) |
{ "Identity": { "Password": { "Cipher": { "KeySize": 2048, "RotationHours": 24, "GracePeriodMinutes": 10, "MaxClockSkewSeconds": 120, "NonceTtlSeconds": 120 } } }}- Distributed mode stores a Data Protection-protected PKCS#8 snapshot under
identity:cipher:keyring:v1, immediately zeroing plaintext key bytes; instances must share the Data Protection key ring. Only memory fallback rotates on restart. - Nonce uses
ICacheStore; without distributed cache the process falls back in-memory (weaker multi-instance replay protection — use Redis in production). - Not every field fails startup:
KeySize/MaxClockSkewSeconds/NonceTtlSecondsdo viaValidateOnStart;RotationHoursis only clamped to1–720;GracePeriodMinutesonly enforces ≥1. - Plain HTTP / IP access: when
crypto.subtleis unavailable the SDK uses a pure-JS RSA-OAEP-SHA256 fallback so password encryption still works; the server auto-sets refresh/trusted-device cookies toSecure=falseand downgradesSameSite=NonetoLaxon non-HTTPS requests (otherwise cookies never stick and refresh returns 401). Prefer HTTPS in production.
X-Client-Platform selects client platform and defaults to Web. The value enters JWT platform, refresh-token slot, and session record. Refresh cannot relabel the original token as another platform.
3. Risk runs before password
IRiskAssessmentEngine receives tenant, user name, IP, User-Agent, and DeviceId. It returns four challenge classes:
| Challenge | LoginFlow behavior | Password verified? |
|---|---|---|
None | Continue | Yes |
Block | Audit failure and return RiskBlocked | No |
Captcha | Generate first; consume answer on second request | Only after captcha passes |
MFA / StepUp | Mark second-factor requirement and continue | Yes |
Risk Block rejects before BCrypt to save high-risk brute-force cost. That branch does not execute dummy BCrypt, but it does write a login failure for future risk evaluation.
4. Captcha is a two-stage protocol
If risk requires captcha and the request has no captchaChallengeId, the service returns a challenge:
{ "accessToken": "", "refreshToken": "", "expiresIn": 0, "requiresMfa": false, "mfaToken": null, "requiresPasswordChange": false, "requiresCaptcha": true, "captchaChallengeId": "captcha-ticket-id", "captchaRenderData": "svg-or-base64-render-data", "isHost": false}After rendering it, the client repeats the login with captchaChallengeId and captchaAnswer. Verification consumes the ticket and prevents replay.
# Stage two must preserve the identity, device, and tenant context from stage one.# ChallengeId is one-time state and must not be replayed after consumption.curl --fail-with-body \ --request POST 'https://localhost:5001/api/auth/login' \ --header 'Content-Type: application/json' \ --data '{ "userName":"alice@example.com", "password":"Correct-Horse-9!", "rememberMe":false, "deviceId":"alice-browser-01", "captchaChallengeId":"captcha-ticket-id", "captchaAnswer":"8241" }'A ticket with no answer, failed verification, or provider failure returns Identity.Login.CaptchaFailed and writes a sanitized failure reason.
5. User resolution and tenant trust
ResolveUserAsync evaluates in this order:
- Find a Host user by UserName in the global
IsHost = truenamespace. - If UserName contains
@, perform cross-tenant email lookup and use the persisted user’s TenantId. - Otherwise, find UserName inside the tenant provided by trusted resolution.
A client cannot invent a TenantId to log in across tenants. Host TenantId = "0" is a platform semantic and cannot masquerade as a normal tenant user.
Host users live in legacy-compatible tenant tables (TenantId = "0"), where the dual-ORM unified tenant-visibility filter intercepts cross-tenant reads. Host login was previously mis-blocked by that filter. The fix does not disable the filter; instead IIdentityLoginPersistenceScope installs a registered RootCrossTenantOperation capability ticket (operation code identity.login.resolve-and-establish-session) to perform a bounded, audited Host exact-key lookup. Once Host identity is confirmed, CurrentUser, CurrentTenant, and the ORM execution context sync so the host-admin role and its permissions enter the session — but only within the confirmed IsHost login scope, which restores via LIFO on exit.
Lookup failure or absence executes dummy BCrypt on the submitted password before returning. This reduces response-time differences that could reveal valid user names.
6. Account state and lockout
The defaults are five failures and a 15-minute lockout. On password failure, VerifyPasswordFailed changes the aggregate and the repository saves it. If failure-count persistence fails, login returns that store error and issues no token.
An active lockout executes dummy BCrypt and rejects. An elapsed lockout calls Unlock() before the normal state guard. Only UserStatus.Active proceeds; Disabled uses Identity.Login.AccountDisabled, and other non-active states use Identity.Login.AccountNotActive.
// An active lockout runs the timing defense before returning a stable locked result.if (user.Status == UserStatus.Locked && user.LockoutEnd > clock.UtcNow){ passwordHasher.VerifyPassword(input.Password, DummyPasswordHash); return LoginResult.Locked(accountLockedError);}
// A correct password cannot bypass PendingActivation, Disabled, or Expired.if (user.Status != UserStatus.Active){ passwordHasher.VerifyPassword(input.Password, DummyPasswordHash); return LoginResult.Locked(accountNotActiveError);}This excerpt focuses on the state decision and leaves login-audit calls to LoginFlow.cs, together with the exact constants, errors, and logging.
7. Correct password may not finish login
After BCrypt succeeds, the aggregate calls VerifyPasswordSucceeded and saves login state. The flow then unions user.TwoFactorEnabled with risk MFA/StepUp.
When a tenant-mandated MFA policy requires a second factor but the user has not enrolled any MFA credential, login returns an MfaEnrollmentRequired branch: a restricted token (mfaEnrollmentRequired: true) is issued that only permits the MFA enrollment flow, not other resources. This ensures a mandatory policy cannot be bypassed by a user simply having no MFA registered.
When MFA is required, IMfaService generates a one-time challenge and stores it in UserToken:
- Provider:
MFA - Name:
Challenge - Lifetime: five minutes
This branch returns requiresMfa = true and creates no final access token, refresh token, or session. The MFA completion use case performs final issuance.
8. JWT claim contract
The final access token contains framework claims plus:
| Claim | Source | Purpose |
|---|---|---|
user_id | UserAggregate.Id | Stable identity for HttpContextCurrentUser |
platform | Login client platform | Client isolation and risk context |
caller_type | Host or User | Distinguish platform operator from tenant user |
| Roles | IUserQueryStore.GetUserRolesAsync | Downstream authorization context |
auth_source | Optional external or mini-program entry | Distinguish issuance channel |
Access tokens last 15 minutes by default. If role lookup fails, the current implementation issues with an empty role set. A product that requires role availability to gate login needs a fail-closed change and tests.
9. Refresh-token families and platform isolation
Refresh tokens last seven days by default and use storage name RefreshToken:{platform}. With IRefreshTokenStore, the service also stores SHA-256-hashed RefreshTokenRecord nodes in Active, Rotated, or Revoked state.
Refreshing Web must not overwrite App. A rotated record inherits the original platform. Reusing a Rotated token can be detected and the family handled.
Without IRefreshTokenStore, JwtTokenService uses its legacy single-token path and lacks full family tracking. Production acceptance must inspect actual DI, not merely the interface declaration.
10. Session write and compensation
After creating access and refresh tokens, login does this:
- Store platform-isolated refresh token.
- Compute SHA-256 hash of access token.
- Create a seven-day
UserSessionwith DeviceId, IP, User-Agent, and platform. - Save the session.
- Audit login success.
Refresh storage failure returns no token. If session persistence fails, LoginFlow revokes the freshly stored refresh token and records issuance failure. LoginFlowTests pin this compensation.
11. Login-log query
Login audit produced by the flow is exposed through two read-only QUERY endpoints:
QUERY /api/login-logsrequires authorization (resourceidentity/loginlog, actionView) and supportsUserId/UserNameplusPageIndex/PageSize; its POST fallback is/api/login-logs/_query.QUERY /api/login-logs/merequires authentication and fixes TenantId/UserId from the current identity; its POST fallback is/api/login-logs/me/_query.
GetLoginLogsQueryRule validates the administrative request before the handler: PageIndex must be at least 1, PageSize must be in 1..1000, and trimmed UserId/UserName filters must be at most 64 characters with no control characters. Whitespace filters become null. Rejection uses Identity.LoginLog.InvalidQuery and does not call the store. The self-service endpoint does not currently have the same request rule; the underlying service normalizes its page through PagingLimits. This boundary still needs convergence.
Hot and archived records are merged as (CreateTime descending, LogId descending) with a maximum combined window of 100000. Pages beyond that window are not a complete long-term browsing contract; use a cursor or a shared hot/cold Query Shape if deeper history is required.
The returned LoginLogDto fields: LogId, UserId, UserName, Result (LoginResult), FailureReason, IpAddress, UserAgent, DeviceInfo, LoginProvider, CreateTime. Result and LoginProvider are enums so the client can classify by success/failure and login channel. Login audit is written by LoginLogService; archival follows a separate retention policy (see Operations backup and archive — LoginLog is one of the two fixed archive policies).
12. Actual password-change and revocation boundaries
| Operation | Current implementation | Do not assume |
|---|---|---|
Logout | Uses the presented refresh token to revoke its authoritative family; no access token is required | It does not remove every device |
RevokeSession | Revokes one session | It is not password change |
RevokeAllSessions | Revokes all user sessions | It must be invoked explicitly |
ChangePassword | Checks old password, policy, history; updates hash and stamp | It currently does not call RevokeAllSessions |
An expired access token must not trap a still-active refresh cookie. The logout endpoint therefore admits the request without Bearer authentication, but it authenticates the action by possession of the high-entropy refresh token and resolves user and tenant only from the authoritative token record. Cookie-backed logout still requires a same-origin or explicitly allowed Origin; native clients present the refresh token in the body.
A security-stamp change invalidates old access tokens immediately only if the authentication pipeline validates the stamp. JWT normally remains usable until expiry, so high-risk products should compose session and refresh revocation and prove their access-token rejection policy.
13. HTTP boundary: Cookie Origin and reverse proxies
Browser login validates the refresh-cookie origin before checking credentials. If the API returns:
"errorCode": "Authentication.Cookie.OriginRejected"the browser Origin does not match the request origin seen by the API. This is not an invalid-password failure.
| Check | Requirement |
|---|---|
| Address bar | Must match Frontend__BaseUrl and Cors__AllowedOrigins__0 exactly (scheme + port) |
| OpenResty → Gateway | Set Host and X-Forwarded-Host from $http_host; send X-Forwarded-Proto and X-Forwarded-Port $server_port |
| API ForwardedHeaders | Trust Gateway via KnownProxies; ForwardLimit covers OpenResty + Gateway; place Forwarded Port middleware immediately afterward |
| Cookie Origin implementation | Compares browser Origin to trusted middleware-corrected Scheme://Host (plus explicit allowlist); unverified raw forwarded headers are not an Origin |
| HTTP preview | Development may set Auth__WebRefreshCookie__Secure=false and SameSite=Lax; production HTTPS keeps Secure |
After reverse proxy hops, Scheme://Host on the API must become the public entry, or you must list that entry in the origin allowlist. See 1Panel Development Preview and OpenAPI and Scalar documentation surface.
14. Client implementation rules
- Do not treat HTTP success alone as completed login; inspect
requiresCaptcha,requiresMfa, and token fields. - Repeat the same identity, credential, device, and tenant context for captcha stage two.
- Do not store an empty access token before MFA completes.
- Keep refresh tokens in platform-specific secure storage; never copy between Web and App.
- Do not reveal user existence for credential, status, or lockout outcomes.
- When
requiresPasswordChangeis true, enter controlled password change and explicitly apply the product’s session-revocation policy. - On preview deployments, rule out
OriginRejectedbefore investigatingInvalidCredentials(Demo seed and passwords).
15. Tests and source navigation
# Application-level login state-machine branches.dotnet test tests/BitzOrcas.Application.Tests/BitzOrcas.Application.Tests.csproj \ --configuration Release \ --filter 'FullyQualifiedName~Identity.LoginFlowTests|FullyQualifiedName~Identity.LoginRiskIntegrationTests|FullyQualifiedName~Identity.VerifyMfaLoginCompletionTests'
# Claims and client-platform isolation.dotnet test tests/BitzOrcas.Unit.Tests/BitzOrcas.Unit.Tests.csproj \ --configuration Release \ --filter 'FullyQualifiedName~Identity.TokenServiceClaimsTests|FullyQualifiedName~Identity.PlatformTokenIsolationTests'
# Confirm the deep login module remains the single orchestrator.rg -n "ILoginFlow|new LoginFlow|GenerateAccessToken" \ src/Platform/Identity src/Hosts/BitzOrcas.Api -g '*.cs'A new authentication entry should reuse ILoginFlow or document why it owns a different state machine. It must not copy password lockout, MFA, and token issuance code.
Identity overview · Configuration and integrations · Testing and operations