This page is a lookup, not a guess. Types, paths, defaults, and constraints are verified against the BitzOrcasVNext mainline sources (contracts in src/Platform/Identity/BitzOrcas.Identity.Contracts/Identity/StepUp/, endpoint declarations under .../Identity.Application/Commands|Queries/, enforcement in src/Hosts/BitzOrcas.Api/StepUp/). Factor wire names are camelCase everywhere (totp, emailOtp), matching the StepUpFactorWire conversion contract.
Endpoint list
| Method and path | Authorization | Purpose |
|---|---|---|
POST /api/identity/step-up/challenge | authenticated | Start a challenge for a purpose; returns the allowed factor set |
POST /api/identity/step-up/otp/send | authenticated | Trigger email/SMS OTP delivery for an existing challenge |
POST /api/identity/step-up/verify | authenticated | Verify a factor; issues the re-authentication credential |
GET /api/identity/step-up/factors/{purpose} | authenticated | Single-purpose decision preview (factors / window / enforced) |
QUERY /api/identity/step-up/my-purposes | authenticated | Projection of purposes that will require step-up for the caller |
QUERY /api/identity/step-up/policies | identity.stepup.manage | List tenant rows plus host baseline rows |
QUERY /api/identity/step-up/policies/catalog | identity.stepup.manage | Purpose catalog projection (selector data source) |
POST /api/identity/step-up/policies | identity.stepup.manage | Create a policy row |
PUT /api/identity/step-up/policies/{id} | identity.stepup.manage | Update a policy row (optimistic concurrency) |
GET / PUT /api/identity/step-up/policies/settings | identity.stepup.manage (write: host context only) | Window ceiling + tenant-configurable surface |
The three challenge-loop endpoints carry a skip-interception marker so they never recurse. The management commands and queries additionally implement delegation-sensitive semantics: impersonation sessions cannot edit policy on behalf of the impersonated user.
Challenge and verification contracts
Start a challenge (StepUpChallengeResponse):
// Request body: the purpose must hit the frozen catalog (permission code or declared purpose).{ "purpose": "identity.user.remove" }
// 200 response: factor set = policy-allowed set intersected with the user's current capabilities.{ "challengeId": "9fJ2vQ8rT3wL5nX1cZ7bA0dK4mH6pS2yE8uG1iO5aB3", "expiresInSeconds": 120, "windowSeconds": 300, "factors": [ { "type": "totp" }, { "type": "emailOtp", "target": "l***@example.com" } ]}| Field | Type | Meaning |
|---|---|---|
challengeId | string | Opaque challenge id; 32-byte CSPRNG base64url, non-enumerable |
expiresInSeconds | int | Challenge validity (factory 120 s); expired challenges must be restarted |
windowSeconds | int | Current policy window for the purpose; 0 means a one-shot ticket |
factors[].type | string | Factor wire name |
factors[].target | string? | Masked delivery target; email/SMS factors only |
OTP delivery (StepUpOtpSendResponse): request { "challengeId": "…", "factorType": "emailOtp" }, response { "resendAllowInSeconds": 60 } — re-sending inside the window returns 429.
Verification (StepUpVerifyResponse): request { "challengeId": "…", "factorType": "totp", "code": "482913" }, response:
// stepUpToken is the re-authentication credential: 32-byte CSPRNG base64url,// returned exactly once. Failed verifications consume attempts (5 per// challenge at factory settings); exhaustion voids the challenge.{ "stepUpToken": "Ak7Qx2Wm9Rf4Tt1Lz8Nb0Vc5Yh3Jd6Pg2Se9Ua1Wr4X=", "purpose": "identity.user.remove", "windowSeconds": 300, "expiresAt": "2026-09-02T08:15:00Z"}Credentials replay on business requests via a dedicated header, named by the StepUp:TokenHeader setting (factory X-Step-Up-Token).
The three 403 ProblemDetails shapes
Every 403 is an RFC 9457 ProblemDetails with extension fields flattened onto the root. Why this deviates from the RFC 9470 §3 401 + WWW-Authenticate track: see concept, industry landscape.
// Shape one: guidance (type suffix step-up-required) — the frontend opens the dialog.// Triggers: missing credential (GrantRequired) or expired window credential (GrantExpired).{ "type": "https://docs.bitzsoft.com/problems/step-up-required", "title": "Step-Up Required", "status": 403, "detail": "该操作需要二次验证。", "errorCode": "Identity.StepUp.GrantRequired", "errorType": "Forbidden", "purpose": "identity.user.remove", "factors": [{ "type": "totp" }, { "type": "emailOtp", "target": "l***@example.com" }], "windowSeconds": 300}
// Shape two: rejection (type suffix step-up-invalid) — no factors, preventing probing.// errorCode is GrantInvalid or GrantPurposeMismatch.{ "type": "https://docs.bitzsoft.com/problems/step-up-invalid", "title": "Step-Up Invalid", "status": 403, "detail": "二次验证凭据与当前操作不匹配。", "errorCode": "Identity.StepUp.GrantPurposeMismatch", "errorType": "Forbidden"}
// Shape three: policy unavailable (fail-closed, never a pass).{ "type": "https://docs.bitzsoft.com/problems/step-up-policy-unavailable", "title": "Step-Up Unavailable", "status": 403, "detail": "安全策略暂不可用,操作已被拒绝。", "errorCode": "Identity.StepUp.PolicyUnavailable", "errorType": "Forbidden"}Full error table
| Error code | HTTP | Trigger |
|---|---|---|
Identity.StepUp.ChallengeNotFound | 400 | Challenge missing, expired, or already consumed |
Identity.StepUp.ChallengeExpired | 400 | Challenge timed out |
Identity.StepUp.CodeInvalid | 400 | Wrong code / recovery code / password; consumes one attempt |
Identity.StepUp.TooManyAttempts | 400 | Attempt limit reached; challenge voided |
Identity.StepUp.FactorNotAllowed | 422 | Requested factor outside the allowed set |
Identity.StepUp.FactorUnavailable | 422 | Factor not bound or unavailable (e.g. recovery codes exhausted) |
Identity.StepUp.EnrollmentRequired | 403 | No usable factor; MFA enrollment required first |
Identity.StepUp.RateLimited | 429 | Resend rate limit hit (with retryAfterSeconds) |
Identity.StepUp.GrantRequired | 403 | Guidance: missing credential |
Identity.StepUp.GrantInvalid | 403 | Invalid credential: subject/tenant mismatch, version revocation, one-shot replay, missing version key |
Identity.StepUp.GrantExpired | 403 | Guidance: window credential expired |
Identity.StepUp.GrantPurposeMismatch | 403 | Credential purpose differs from the endpoint; high-severity audit |
Identity.StepUp.PolicyUnavailable | 403 | Policy/credential storage read failure, fail-closed |
Identity.StepUp.PolicyConflict | 422 | Host ceiling, whitelist, mandatory tightening, or duplicate-scope violation |
Identity.StepUp.PurposeUnknown | 422 | Purpose code outside the startup-frozen catalog |
Identity.StepUp.ChallengeNotAllowed | 403 | Machine caller or restricted session touching the challenge loop |
Identity.StepUp.PolicyInvalid | 422 | Policy row shape invalid (empty factors, missing/oversized scope values) |
Identity.StepUp.PolicyNotFound | 404 | Policy row missing or deleted |
Identity.StepUp.PolicyVersionConflict | 422 | Optimistic version conflict; refresh and retry |
Identity.StepUp.PolicyHostOnly | 403 | Control-plane settings writable only from the host context |
Configuration keys (StepUp section)
Type StepUpOptions (StepUpOptions.cs), bound to the StepUp section and validated at host startup. Factory defaults are engineering suggestions, not external-standard clauses — tune them against observation data:
| Key | Factory default | Constraint |
|---|---|---|
WindowDefaultSeconds | 300 | ≥ 0; 0 means one-shot |
WindowCeilingSeconds | 900 | > 0; tenant-row ceiling; ≤ technical cap |
WindowTechnicalMaxSeconds | 86400 | ≥ ceiling; misconfiguration guard, not a security policy |
ChallengeTtlSeconds | 120 | > 0 |
MaxAttemptsPerChallenge | 5 | > 0 |
OtpResendAllowSeconds | 60 | > 0 |
VersionKeySafetyPaddingSeconds | 60 | > 0; version-key TTL = ceiling + challenge TTL + padding |
TokenHeader | X-Step-Up-Token | non-blank |
PolicySnapshotTtlSeconds | 60 | > 0; snapshot fallback convergence |
RoleMembershipTtlSeconds | 300 | > 0; role-membership fallback convergence |
appsettings.json sample (equivalent to factory defaults):
{ "StepUp": { "WindowDefaultSeconds": 300, "WindowCeilingSeconds": 900, "WindowTechnicalMaxSeconds": 86400, "ChallengeTtlSeconds": 120, "MaxAttemptsPerChallenge": 5, "OtpResendAllowSeconds": 60, "VersionKeySafetyPaddingSeconds": 60, "TokenHeader": "X-Step-Up-Token", "PolicySnapshotTtlSeconds": 60, "RoleMembershipTtlSeconds": 300 }}Rate limits and storage keys
The four challenge-loop endpoints use dedicated sliding-window limits (section RateLimiting:StepUp; partition = tenant + subject + endpoint scope):
| Endpoint | Setting | Factory per minute |
|---|---|---|
| challenge | ChallengePerMinute | 10 |
| verify | VerifyPerMinute | 5 |
| otp/send | OtpSendPerMinute | 5 |
| factors | FactorsPerMinute | 30 |
Redis keys are built by the unified cache-key builder as {app}:{env}:v1:stepup:…:
| Key shape | Semantics | TTL |
|---|---|---|
global:ch:{challengeId} | Challenge record | Challenge TTL (120 s) |
global:ch:{challengeId}:attempts | Failure counter (atomic Lua increment, linked delete at limit) | With the challenge |
global:grant:{token} | Credential: window mode uses GET (read-only), one-shot uses atomic GETDEL | Window seconds |
global:tenant-{tid}:ver:{subjectKey} | Revocation version counter (INCR / SETNX init) | Ceiling + challenge TTL + padding (factory 1080 s) |
Known limitations
- The
fido2factor is a contract placeholder: enum and wire name reserved, admin UI and dialog do not render it, challenges never offer it — until the WebAuthn assertion path lands (planned). - Factory baselines ship in two layers: the composition-root code declaration list carries one pilot purpose (
operations.sql-masking.manage, mandatory + one-shot tickets), and every declared purpose passes a wiring check requiring a real endpoint behind the same permission code — a drift fails host startup. Demonstration-scope deployments additionally seed two policy-row-only purposes with audit-only rows as the rollout starting point (idempotent, never reverts progressed rows). Other purposes without a host row fall back to the declared baseline; baseline rows can be configured on the host “security baseline” page. - When verification outlives the access token’s remaining lifetime, the frontend abandons the replay (retrying self-heals via the memory cache); tabs do not share credentials.