This page answers three production questions: which code reads a setting, what happens when it is absent, and how to prove that a real adapter has replaced the placeholder. A Host that starts from configuration is not necessarily commercially ready.
1. Registration order
Register Identity application services before Infrastructure adapters. TryAdd defaults allow later concrete registrations to replace them; incorrect order can leave a Null or Unavailable implementation active.
// 1. CoreRuntime registers Identity policy, enhanced-MFA entry, and fail-closed defaults.services.AddBitzOrcasCoreRuntime(configuration);
// 2. With both database and RabbitMQ configuration, register generated stores,// notification delivery, and federation. API Shell keeps CoreRuntime defaults.services.AddBitzOrcasPersistenceAdapters(configuration);
// 3. Authentication schemes and API pipeline consume the closed Identity service graph.services.AddBitzOrcasAuthentication(configuration, environment);services.AddBitzOrcasApiPipeline(configuration);AddBitzOrcasCoreRuntime calls AddBitzOrcasPlatformIdentity and AddBitzOrcasMfaConnectors; the production branch of AddBitzOrcasPersistenceAdapters registers notification connectors and AddBitzOrcasIdentityFederationConnectors. When creating a Host, copy the verified composition relationship rather than calling internal extensions twice.
2. Password settings
| Key | Default | Meaning |
|---|---|---|
Identity:Password:RequireDigit | true | At least one digit |
RequireLowercase | true | At least one lowercase character |
RequireUppercase | true | At least one uppercase character |
RequireNonAlphanumeric | true | At least one symbol |
RequiredLength | 8 | Minimum characters |
RequiredUniqueChars | 1 | Minimum distinct characters |
Identity:Password:BCrypt:WorkFactor | 12 | BCrypt cost |
Identity:Password:Expiry:PasswordExpiryDays | 90 | 0 means no expiry |
PasswordExpiryWarningDays | 7 | Advance warning |
Identity:Password:History:PasswordHistoryCount | 5 | Configured history count |
2.1 Password client encryption Identity:Password:Cipher
Bound type: PasswordCipherOptions (PasswordCipherOptions.SectionName).
The product baseline requires RSA-OAEP-SHA256 client encryption with no plaintext password fallback. Omitting the section uses the defaults below; login still works because the SDK calls GET /api/auth/cipher-key and encrypts.
| Key | Default | Valid / effective range | Out-of-range behavior | Meaning |
|---|---|---|---|---|
Identity:Password:Cipher:KeySize | 2048 | 2048–4096 | ValidateOnStart fails (process will not start); key creation also falls back to 2048 if still out of range | RSA bits; distributed mode protects a PKCS#8 private-key snapshot, while fallback mode holds an in-process RSA |
RotationHours | 24 | 1–720 (cap = 720 hours = 30 days) | No startup validation; CipherKeyRotationHostedService applies Math.Clamp(..., 1, 24*30) silently | Rotation interval in hours. 0 becomes 1; 1000 becomes 720 |
GracePeriodMinutes | 10 | Runtime lower bound 1 minute; no startup upper bound | Math.Max(1, GracePeriodMinutes) | How long the previous key still decrypts. Keep much smaller than the rotation interval; recommend 5–30. SDK refreshes the public key once on KeyExpired |
MaxClockSkewSeconds | 120 | 30–600 (10 minutes) | ValidateOnStart fails | Allowed payload timestamp skew vs server clock; also returned on cipher-key for client clock correction |
NonceTtlSeconds | 120 | 30–600 (10 minutes) | ValidateOnStart fails; claim path also Clamp(30, 600) | One-time Nonce cache TTL (seconds); anti-replay |
{ "Identity": { "Password": { "RequireDigit": true, "RequireLowercase": true, "RequireUppercase": true, "RequireNonAlphanumeric": true, "RequiredLength": 12, "RequiredUniqueChars": 6, "BCrypt": { "WorkFactor": 12 }, "Expiry": { "PasswordExpiryDays": 90, "PasswordExpiryWarningDays": 7 }, "History": { "PasswordHistoryCount": 5 }, "Cipher": { "KeySize": 2048, "RotationHours": 24, "GracePeriodMinutes": 10, "MaxClockSkewSeconds": 120, "NonceTtlSeconds": 120 } } }}Validation vs runtime clamp (do not confuse)
| Field | Startup ValidateOnStart | Runtime |
|---|---|---|
KeySize | Yes (2048–4096) | Key creation also falls back to 2048 |
RotationHours | No | Clamp(1, 720); values above 720 still start the process but rotate every 30 days |
GracePeriodMinutes | No | At least 1 minute |
MaxClockSkewSeconds / NonceTtlSeconds | Yes (30–600) | Nonce TTL clamped again when claimed |
So if someone sets RotationHours: 8760 (one year) expecting fewer rotations, the host still starts, but rotation is capped at 720h (30 days). Treat this doc and the source Math.Clamp(..., 1, 24 * 30) as source of truth — not “whatever is written in appsettings”.
Key lifecycle and cipher-key response
| Item | Behavior (matches source) |
|---|---|
| Generation | Distributed mode generates a shared first key when the cache key is absent; GetOrCreateAsync suppresses concurrent multi-instance factories. Only memory fallback generates at each process start |
| keyId | {yyyyMMddHHmmss}-{6 hex}, e.g. 20260811120000-a1b2c3 |
| Public key | RSA SubjectPublicKeyInfo (SPKI) Base64; algorithm id fixed RSA-OAEP-SHA256 |
| Private key | Distributed mode immediately protects exported PKCS#8 with a dedicated Data Protection purpose and zeroes plaintext bytes; memory mode retains only an in-process RSA. Neither mode writes config or logs |
| Rotation cadence | CipherKeyRotationHostedService sleeps RotationHours first, then RotateNow() — not an immediate rotate on start. Current becomes previous; purged after GracePeriodMinutes |
| Ring size | At most current + previous decrypt; older keyIds → KeyExpired |
| Distributed cache key | identity:cipher:keyring:v1; TTL covers rotation, grace, and a one-hour buffer with 5% jitter |
GET /api/auth/cipher-key | keyId, publicKey, algorithm, serverTimestamp (UTC ms), maxClockSkewSeconds |
| Payload | nonce|timestamp|password then RSA-OAEP-SHA256; password may contain | (only the first two separators are used) |
| Nonce cache key | Area identity:cipher:nonce + nonce; prefers ICacheStore |
Ops notes and current boundaries
- Horizontal scale / multi-instance: when both
ICacheStoreandICipherKeyMaterialProtectorare available, the current + previous snapshot is shared in distributed cache. Instance B can decrypt ciphertext encrypted with a public key served by instance A. Every instance must share the same ASP.NET Core Data Protection key ring; otherwise private-key unprotection fails asKeyExpired. - Fallback mode: if either the cache or protector is absent, the service logs a Warning and uses an in-process ring. Cross-node requests can then still return
KeyExpired. Fix production composition; use sticky sessions only as a temporary mitigation. The SDK’s single refresh retry absorbs a race but is not a scaling design. - Nonce storage prefers
ICacheStore(e.g. Redis). Without it, the process falls back to an in-memory dictionary and logs a Warning — multi-instance replay protection is single-node only; use Redis in production. - Algorithm is fixed RSA-OAEP + SHA-256; payload/flow details: login security.
- Raising
KeySize(3072/4096) increases CPU and ciphertext size; load-test before shipping. - Large
RotationHours(near 720) lengthens single-key exposure; very small values increaseKeyExpiredand SDK retries. Default24fits most deployments. GracePeriodMinutesshould cover “client cached public key → submit” latency and stay well below the rotation interval; multi-hour grace weakens rotation benefits.- Error codes:
Identity.Cipher.KeyExpired,DecryptFailed,ClockSkewExceeded,ReplayDetected,EncryptedPasswordRequired,PayloadInvalid.
Benchmark login throughput and CPU on production-sized hardware before raising BCrypt cost. Do not lower it below security policy for throughput, and do not double it without capacity evidence.
3. JWT base settings
| Key | Default | Production requirement |
|---|---|---|
Jwt:Issuer | BitzOrcas | Match validation side |
Jwt:Audience | BitzOrcas | Match API resource |
Jwt:Secret | Empty | At least 32 characters, supplied by secret management |
Jwt:AccessTokenExpirationMinutes | 15 | Shorter increases refresh load; longer increases exposure |
Jwt:RefreshTokenExpirationDays | 7 | Aligns with default seven-day session semantics |
A single Secret is acceptable for development. Production should prefer SigningKeys with Kid, allowing issuance from the active key and validation with grace-period keys.
{ "Jwt": { "Issuer": "https://identity.example.com", "Audience": "bitzorcas-api", "AccessTokenExpirationMinutes": 15, "RefreshTokenExpirationDays": 7, "SigningKeys": [ { "Kid": "identity-2026-q3", "Secret": "secret-injected-by-managed-key-provider-at-runtime", "ActiveFrom": "2026-07-01T00:00:00Z", "ActiveTo": null } ], "Rotation": { "Enabled": true, "RotationIntervalDays": 90, "GracePeriodDays": 7, "CronExpression": "0 0 2 * * ?" } }}The Secret above is a placeholder. Never commit a real key. NullJwtKeyRotationSink only records rotation; managed-key persistence requires a production IJwtKeyRotationSink.
4. Frontend notification links
FrontendLinkBuilder reads Frontend:BaseUrl and the route dictionary. The default BaseUrl is http://localhost:6800 and is local-development only.
{ "Frontend": { "BaseUrl": "https://portal.example.com", "Routes": { "ResetPassword": "/reset-password", "Activate": "/activate", "VerifyEmail": "/verify-email", "ConfirmPhone": "/confirm-phone" } }}BaseUrl must be HTTPS, fixed to a trusted host, and have no trailing slash. Routes are server configuration and must not accept an arbitrary ReturnUrl. Place notification tokens where the frontend can submit them immediately without exposing them to analytics, Referer, or logs.
5. Defaults and failure semantics
AddBitzOrcasPlatformIdentity registers:
BCryptPasswordHasher,DefaultPasswordValidator, andJwtTokenService.LoginFlow, password-expiry, and password-history services.- Built-in
TotpMfaService. UnavailableAuthProviderfor AzureAD, WeChat, WeCom, and DingTalk.UnavailableWeChatMiniProgramAuthService.NullEmailDeliveryPortandNullSmsDeliveryPort.NullLoginLogArchiveQueryPortandNullJwtKeyRotationSink.
Unavailable federation should return a stable unavailable error. Null delivery is best effort. Neither should make a readiness probe claim that the related business capability is ready.
6. Federation adapters
After AddBitzOrcasIdentityFederationConnectors, each adapter registers independently:
| Connector | Condition | Concrete adapter |
|---|---|---|
| SocialAuth | SocialAuth section exists | SocialAuthAdapter |
| Azure AD | AzureAD:Enabled=true and ClientId present | AzureAdAdapter, plus Graph, JIT, and JwtBearer |
| LDAP | Ldap:IsEnabled=true and Host present | LdapAuthAdapter |
| WeChat mini program | Both AppId and AppSecret present | WeChatMiniProgramAdapter on its own port |
{ "AzureAD": { "Enabled": true, "Instance": "https://login.microsoftonline.com/", "TenantId": "organization-tenant-id", "ClientId": "application-client-id", "ClientSecret": "injected-secret", "CallbackPath": "/api/callback/azuread", "Scopes": ["openid", "profile", "email"] }}Application and Contracts must not reference connector packages. Vendor SDKs, HttpClient, and error translation stay in Infrastructure. A successful external identity still enters local account, tenant, role, and session semantics.
6.1 Tenant-level Provider management
Static Host configuration loads protocol adapters. Tenant runtime parameters and credentials are managed through an API and take effect without a process restart. Code-registered Profiles define allowed fields, defaults, interaction, and required Adapter; the tenant-owned ExternalLoginProviderConfiguration aggregate references only a stable ProfileId.
The current Profile catalog is:
| ProfileId | ProviderKey | Interaction | Required core fields | Write-only credential | JIT / connection test |
|---|---|---|---|---|---|
azure-ad | AzureAD | Redirect | TenantId, ClientId, Scopes containing openid, and UserIdClaim | ClientSecret | Both supported |
social-oauth2 | Social | Redirect | public HTTPS Authorization/Token/UserInfo endpoints, ClientId, scopes, and identity claims | ClientSecret | Both supported |
ldap | Ldap | Credentials | Host, port 1–65535, SSL, SearchBaseDn, object class, 500–30000ms timeout, plus a user-DN template or UPN suffix | BindPassword; required when BindDn is set | Both supported |
wechat-mini-program | WeChatMiniProgram | MiniProgram | AppId | AppSecret | Both supported |
Social OAuth endpoints must be public HTTPS URLs with no UserInfo, query, or fragment. SAML has its own dynamic configuration, metadata validation, and runtime-snapshot lifecycle, so it is deliberately not duplicated in these four Profiles.
Management API and authorization
| Method and path | Behavior | Transaction/concurrency |
|---|---|---|
GET /api/external-login/configurations | code Profiles, Adapter load state, and redacted configuration for the current tenant | standard read timeout |
PUT /api/external-login/configurations/{profileId} | create/update display name, enabled state, JIT, complete parameters, and this request’s secret changes | optimistic Version; delegation-sensitive |
DELETE /api/external-login/configurations/{profileId}?Version={version} | versioned tenant soft delete | missing target is idempotent; delegation-sensitive |
POST /api/external-login/configurations/{profileId}/test | real connection test using saved configuration | INonTransactionalCommand, so network I/O holds no DB transaction |
All four use cases require Manage on resource identity/externallogin, represented by permission identity.externallogin.manage. The frontend entry is the External Login sheet in Integration Center. It is a client of the management API, not a second configuration source of truth.
Parameters in a save request is the complete non-sensitive set. Secrets contains only values created or replaced in this request, while ClearedSecretKeys explicitly removes values. Existing credentials omitted from both are preserved. Reads return only ConfiguredSecretKeys; they never return credential values, SecretsJson, or its search hash. The persistence column uses the standard Data Protection sensitive-column pipeline. Runtime secrets do not enter responses, logs, exceptions, cache keys, or telemetry labels.
Before enabling a configuration, the service checks the Profile schema, required credentials, and whether a real Adapter is loaded. Stable readiness values are Ready, Disabled, Incomplete, AdapterUnavailable, and NotConfigured. A connection test uses saved configuration and returns only success, UTC time, latency, stable error code, and a redacted message.
Every public Provider listing, login initiation, callback, mini-program exchange, and connection test re-reads the current tenant’s authoritative Store; credentials are not cached. Rotation therefore takes effect immediately. Redirect flows also compare {configurationId}:{version} so a callback cannot continue under credentials changed after initiation. Explicit disable or delete removes the Provider from the public login list and does not fall back to legacy static configuration.
7. Enhanced MFA connector
Built-in TOTP remains available without the connector. With MFA:Enabled=true, AddBitzOrcasMfaConnectors adds email/SMS OTP, FIDO2, recovery codes, trusted devices, and risk-driven policy.
The default connector MfaCacheProvider is in process. A hosted service warns outside Development. Multi-instance production must replace it with Redis or another distributed implementation; otherwise a FIDO2 challenge generated on one instance may be verified on another and fail.
NullUserMfaConfigRepository is also a placeholder. When enhanced MFA is enabled, verify concrete user-MFA storage, email/SMS delivery, and challenge cache.
8. Notification adapters
Activation, password reset, email confirmation, and phone confirmation consume IEmailDeliveryPort or ISmsDeliveryPort. Map provider states to operating semantics:
| Provider state | Use-case result | Operator action |
|---|---|---|
| Accepted | Success with message identifier | Track final receipt |
| Retryable timeout | Failure or reliable Outbox | Bounded retry and alert |
| Permanently invalid address | Typed business failure | Ask user to correct address |
| Provider not configured | Fail commercial flow closed | Red readiness and block GA |
The current Null port can let a call complete without delivery, so the GA gate must inspect the concrete implementation or run an end-to-end delivery probe.
9. Permission catalog
IdentityPermissions currently owns:
- Users: list, read, create, update, delete, enable, disable, lock, unlock.
- Roles: list, read, create, update, delete.
- Login logs: list, read.
- MFA: manage.
- Devices: list, manage.
- Sessions: list, manage.
- External login: manage.
- Organization units: list, read, create, update, delete.
Invitation, Admission, and Tenant commands also express resource authorization through ResourceDescriptor and AuthorizationAction. Catalog permissions and resource actions are complementary; neither alone is the full decision.
10. Sensitive configuration rules
- Host-level JWT and adapter-bootstrap credentials come from secret stores or environment injection; tenant external-login credentials enter only through the management API and the protected
SecretsJsonsensitive column. PlatformTenant.ConnectionStringnever enters normal configuration responses, logs, or audit detail.- Password hashes, security stamps, raw tokens, and FIDO2 challenges are not structured-log fields.
- Configuration snapshots and diagnostic endpoints apply fixed masking rather than retaining prefixes and suffixes.
- Rotation leaves old keys for validation grace but never continues issuing with retired keys.
11. Startup verification
# Inspect option binding and default implementations.rg -n "Bind\(|TryAdd|Unavailable|Null.*Port|NullJwt" \ src/Platform/Identity/BitzOrcas.Identity.Application/Identity/DependencyInjection.cs
# Inspect actual connector conditions and replacement registrations.rg -n "GetSection|GetValue|AddScoped<IExternalAuthProvider|MFA:Enabled" \ src/Platform/Identity/BitzOrcas.Identity.Infrastructure -g '*.cs'
# Committed appsettings should not contain an apparent real secret.rg -n '"(Secret|ClientSecret|AppSecret|Password)"\s*:\s*"[^$<{]' \ src/Hosts -g 'appsettings*.json'The last command is heuristic and requires review. It does not replace a secret scanner.
12. Production acceptance table
| Check | Passing evidence |
|---|---|
| Password policy | Weak-password and history-reuse integration tests |
| Key ring | New-Kid issuance, old-Kid grace validation, expired-key rejection |
| Refresh-token family | Production IRefreshTokenStore in DI and reuse test passes |
| Real notification | Probe message, provider receipt, failure alert |
| Federation | Profile/Adapter status, redacted configuration, connection test, callback, and JIT/binding evidence |
| Multi-instance MFA | Distributed challenge cache and cross-instance test |
| Permission catalog | Governance output and authorization contract tests |