Skip to content
bitzorcas
中EN

Guide

Step-Up Frontend Integration

How the first-party SPA implements the re-authentication protocol: the four interceptor replay rules, the verification dialog state machine, the memory-only credential red line, and SDK usage. Business code stays unaware.

Last updated

The backend compresses “does this need re-authentication?” into a structured 403; the frontend turns it into a verification experience that never interrupts business code. The design goal is zero awareness in business code: a page calls client.request(...), the SDK interceptor handles the whole protocol — receiving the guidance 403, opening the global dialog, replaying with the credential, and returning the replayed result as if it were the first call.

Implementation lives in two packages (repository BitzOrcas.Modern): protocol interception in packages/platform-sdk/src/client/request-pipeline.ts, credential cache and concurrency dedup in packages/platform-sdk/src/security/step-up-session.ts, and the dialog in apps/app/src/components/step-up-dialog.tsx.

The four interceptor replay rules

The interceptor does not react to every 403. Four rules together decide whether to intercept and whether to pass through:

  1. Tight match condition: intercept only when the caller is authenticated, the status is 403, the ProblemDetails type ends with step-up-required, and the response root carries a non-empty purpose string. Plain 403s (authorization), step-up-invalid, and step-up-policy-unavailable surface untouched — no dialog, no retry; the caller decides.
  2. One replay hop per request: the first 403 triggers verification and one replay; if the replay still returns a guidance 403 (say the policy was switched off between hops), that 403 becomes the call result directly and does not re-enter interception — no infinite loops.
  3. Memory-only window cache: window credentials (windowSeconds > 0) are cached per purpose in a tab-memory Map; within the window, same-purpose requests replay from cache without a dialog. One-shot credentials (windowSeconds = 0) are never cached. Never write credentials to localStorage / sessionStorage — a refresh clears them naturally and the XSS surface stays minimal. This is a red line, not a suggestion.
  4. Concurrency dedup and logout clearing: concurrent same-purpose 403s share one in-flight verification (Promise dedup; failures do not poison retries); logout, refresh failure, and the 401 terminal state clear all cached credentials and in-flight state. Tabs do not share credentials — each verifies independently, and the server’s window semantics arbitrate.
// From the business code's perspective: no step-up traces at all — the
// interceptor handles everything transparently.
const result = await api.removeTeamMember(teamId, userId);
if (result.ok) {
// Either "no re-auth needed" or "the interceptor verified and replayed".
render(result.data);
} else if (result.error.errorCode === "Identity.StepUp.GrantPurposeMismatch") {
// Rejection-shaped 403s reach business code untouched: this is an
// attack-signal-grade error — surface it and report.
reportAnomaly(result.error);
}
// Plain 403 / step-up-invalid / policy-unavailable also arrive untouched,
// ready to be handled separately.

Testing these semantics needs no real backend: vi.stubGlobal("fetch", …) with crafted 403 responses drives the interceptor; packages/platform-sdk/src/client/request-pipeline.stepup.test.ts in the BitzOrcas.Modern repository is the complete sample (replay header injection, replay cap, concurrency sharing, cancel semantics, the four pass-through shapes).

The dialog state machine

A single global dialog is mounted in the auth shell, driven by the stepup:required / stepup:completed / stepup:cancelled window events (never shared across tabs). The state machine covers every failure branch of the challenge loop:

stepup:requiredemail/SMS OTP chosendelivered (resendcountdown)factor unavailable (greyed)TOTP / recovery code /password chosenverify succeededwrong code (CodeInvalid)ChallengeExpiredTooManyAttempts /RateLimitedrestart (new challengeId)countdown elapsedEnrollmentRequiredjump to MFA enrollmentstepup:completedcancel (stepup:cancelled,caller gets original 403)

FactorSelect

OtpSending

CodeInput

Completed

Expired

Locked

Enrollment

Three state-machine points, each mapping to a real failure branch rather than the happy path:

  • Factor switching reuses the same challenge: the challenge record holds the entire allowed factor set — after selecting email OTP (already sent), switching to TOTP just changes the verify parameters, without rebuilding the challenge or resetting consumed rate-limit budget.
  • Cancellation returns the original 403: pressing Esc or cancel rejects the in-flight verification promise and the interceptor returns the very first 403 to the caller — a user cancel must never masquerade as success or timeout.
  • Expiry restarts explicitly: ChallengeExpired returns the dialog to factor selection with a fresh challengeId; if rebuilding fails, in-flight tasks are cleared so business requests never hang.

SDK usage and mounting

The dialog is registered once at the app shell; business routes need no wiring:

// File: the auth-shell component (mounted next to the impersonation confirm).
// The handler is the verification orchestration: given a purpose it opens the
// dialog and resolves with the credential outcome after successful verification.
usePlatform().registerStepUpChallengeHandler((payload) =>
openStepUpDialog({
purpose: payload.purpose, // unmapped purposes fall back to the raw code
}),
);
// Challenge-loop endpoint wrappers carry skipStepUpIntercept (recursion guard);
// you rarely call these directly — the interceptor's orchestration uses them.
import { createStepUpApi } from "@bitz/platform-sdk";
const stepUpApi = createStepUpApi(client);
const preview = await stepUpApi.listStepUpFactors("identity.user.remove");
// preview.data.isRequired === false (toggle off / machine caller / purpose not
// enforced) means calls to that purpose will not demand re-authentication —
// useful for explanatory UI on the account-security page.

The purpose-to-action display map lives in apps/app/src/pages/identity/components/step-up-messages.ts (zh/en pairs); unmapped purposes fall back to the raw code — when you add a sensitive operation, add one map line so users never see a bare permission code.

Testing and self-check

The frontend design is testable by construction: the verification orchestration is injected via registerStepUpChallengeHandler, so tests can bypass the dialog with a fake handler and drive protocol semantics directly. Self-check list:

  • The first request never carries the credential header; the replay carries it and overrides any stale caller header;
  • A still-failing replay surfaces exactly one 403 (the replay cap);
  • Cancellation returns the original 403 with no dialog remnants;
  • DevTools Local/Session Storage contains no credential records (red-line check);
  • After logout, the same purpose prompts again (frontend cache clear + server-side version revocation, two independent guarantees).

Corresponding automated tests live in packages/platform-sdk/src/client/request-pipeline.stepup.test.ts (15 cases) and apps/app/tests/step-up-policy-panel-runtime.test.tsx (admin-page interactions); extend them as needed.

100%

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