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:
- Tight match condition: intercept only when the caller is authenticated, the status is 403, the ProblemDetails
typeends withstep-up-required, and the response root carries a non-emptypurposestring. Plain 403s (authorization),step-up-invalid, andstep-up-policy-unavailablesurface untouched — no dialog, no retry; the caller decides. - 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.
- 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 tolocalStorage/sessionStorage— a refresh clears them naturally and the XSS surface stays minimal. This is a red line, not a suggestion. - 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:
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
verifyparameters, 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:
ChallengeExpiredreturns the dialog to factor selection with a freshchallengeId; 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.
Related topics
- Concept and architecture: why the 403 contract deviates from RFC 9470
- Quickstart: the backend-side integration paths
- Rollout and operations: where observation data comes from, how to tune thresholds