Skip to content
bitzorcas
中EN

Reference

Step-Up Contract Reference

Every public surface of sensitive-operation re-authentication: ten endpoints, challenge and verification JSON contracts, three 403 ProblemDetails shapes, the full error table, and StepUpOptions keys — each verified against source.

Last updated

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 pathAuthorizationPurpose
POST /api/identity/step-up/challengeauthenticatedStart a challenge for a purpose; returns the allowed factor set
POST /api/identity/step-up/otp/sendauthenticatedTrigger email/SMS OTP delivery for an existing challenge
POST /api/identity/step-up/verifyauthenticatedVerify a factor; issues the re-authentication credential
GET /api/identity/step-up/factors/{purpose}authenticatedSingle-purpose decision preview (factors / window / enforced)
QUERY /api/identity/step-up/my-purposesauthenticatedProjection of purposes that will require step-up for the caller
QUERY /api/identity/step-up/policiesidentity.stepup.manageList tenant rows plus host baseline rows
QUERY /api/identity/step-up/policies/catalogidentity.stepup.managePurpose catalog projection (selector data source)
POST /api/identity/step-up/policiesidentity.stepup.manageCreate a policy row
PUT /api/identity/step-up/policies/{id}identity.stepup.manageUpdate a policy row (optimistic concurrency)
GET / PUT /api/identity/step-up/policies/settingsidentity.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" }
]
}
FieldTypeMeaning
challengeIdstringOpaque challenge id; 32-byte CSPRNG base64url, non-enumerable
expiresInSecondsintChallenge validity (factory 120 s); expired challenges must be restarted
windowSecondsintCurrent policy window for the purpose; 0 means a one-shot ticket
factors[].typestringFactor wire name
factors[].targetstring?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 codeHTTPTrigger
Identity.StepUp.ChallengeNotFound400Challenge missing, expired, or already consumed
Identity.StepUp.ChallengeExpired400Challenge timed out
Identity.StepUp.CodeInvalid400Wrong code / recovery code / password; consumes one attempt
Identity.StepUp.TooManyAttempts400Attempt limit reached; challenge voided
Identity.StepUp.FactorNotAllowed422Requested factor outside the allowed set
Identity.StepUp.FactorUnavailable422Factor not bound or unavailable (e.g. recovery codes exhausted)
Identity.StepUp.EnrollmentRequired403No usable factor; MFA enrollment required first
Identity.StepUp.RateLimited429Resend rate limit hit (with retryAfterSeconds)
Identity.StepUp.GrantRequired403Guidance: missing credential
Identity.StepUp.GrantInvalid403Invalid credential: subject/tenant mismatch, version revocation, one-shot replay, missing version key
Identity.StepUp.GrantExpired403Guidance: window credential expired
Identity.StepUp.GrantPurposeMismatch403Credential purpose differs from the endpoint; high-severity audit
Identity.StepUp.PolicyUnavailable403Policy/credential storage read failure, fail-closed
Identity.StepUp.PolicyConflict422Host ceiling, whitelist, mandatory tightening, or duplicate-scope violation
Identity.StepUp.PurposeUnknown422Purpose code outside the startup-frozen catalog
Identity.StepUp.ChallengeNotAllowed403Machine caller or restricted session touching the challenge loop
Identity.StepUp.PolicyInvalid422Policy row shape invalid (empty factors, missing/oversized scope values)
Identity.StepUp.PolicyNotFound404Policy row missing or deleted
Identity.StepUp.PolicyVersionConflict422Optimistic version conflict; refresh and retry
Identity.StepUp.PolicyHostOnly403Control-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:

KeyFactory defaultConstraint
WindowDefaultSeconds300≥ 0; 0 means one-shot
WindowCeilingSeconds900> 0; tenant-row ceiling; ≤ technical cap
WindowTechnicalMaxSeconds86400≥ ceiling; misconfiguration guard, not a security policy
ChallengeTtlSeconds120> 0
MaxAttemptsPerChallenge5> 0
OtpResendAllowSeconds60> 0
VersionKeySafetyPaddingSeconds60> 0; version-key TTL = ceiling + challenge TTL + padding
TokenHeaderX-Step-Up-Tokennon-blank
PolicySnapshotTtlSeconds60> 0; snapshot fallback convergence
RoleMembershipTtlSeconds300> 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):

EndpointSettingFactory per minute
challengeChallengePerMinute10
verifyVerifyPerMinute5
otp/sendOtpSendPerMinute5
factorsFactorsPerMinute30

Redis keys are built by the unified cache-key builder as {app}:{env}:v1:stepup:…:

Key shapeSemanticsTTL
global:ch:{challengeId}Challenge recordChallenge TTL (120 s)
global:ch:{challengeId}:attemptsFailure 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 GETDELWindow seconds
global:tenant-{tid}:ver:{subjectKey}Revocation version counter (INCR / SETNX init)Ceiling + challenge TTL + padding (factory 1080 s)

Known limitations

  • The fido2 factor 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.

100%

Scroll or use controls to zoom · drag when enlarged · double-click for 100% / 200%