Skip to content

Admin Stepup (Re-verification)

Some admin actions are sensitive enough that being signed in is not enough: changing who holds which role, for example. Stepup asks the person to prove it is still them, right now, using a method their account actually has, before the action goes ahead.

It replaces typing a password into the form. A password prompt only works for accounts that have a password, and plenty of Badger identities don't: they sign in with Google, Apple, Badger Sett, a magic link or a passkey.

How it works

 guarded request ──► StepupInterceptor ── no proof, no grant ──► /stepup-launcher
                                                                      │ signed launch token
                                                                      ▼
                                                      auth server  GET /stepup?token=…
                                                                      │ passkey │ password │ SSO
                                                                      ▼
 /stepup-return (tenant)  ◄── auto-submitted POST with a signed, single-use proof
        │  verifies the proof, spends its nonce, sets a grant cookie
        ▼
 303 to the page you were on ──► (next guarded request carries the grant cookie) ──► allowed
Piece Where Does
@RequiresStepup(purpose = "…") web-mvc/.../security/RequiresStepup.java Marks a handler method (or class) as guarded
StepupInterceptor same package Lets a guarded request through on a valid proof or grant; otherwise redirects (302, or 401 + headers for XHR)
StepupProofService same package Verifies proofs: signature, expiry, purpose, same user, single-use nonce (Redis, fails closed)
StepupGrantService same package The grace window (see below)
StepupLauncherController same package GET /stepup-launcher: mints the launch token and redirects to the auth server
StepupReturnController same package POST /stepup-return: where the auth server sends the proof back
StepupController auth/.../controller/ The challenge page and its handlers
StepupSsoService auth/.../service/ The upstream-provider side of the SSO challenge
StepupRateLimitFilter, StepupRateLimiter auth/... Per-IP (filter) and per-identity (controller) rate limits

Tokens are STEPUP-type tokens from EmbedTokenService, signed with the tenant's widget signing key (current key, then the previous one during rotation). A launch token names the user, the purpose and the return URL; a proof adds the method and a single-use nonce and lives 60 seconds.

Challenges

The auth server offers only what the identity really has, in this order:

  1. Passkey, if any WebAuthn credential is registered.
  2. Password, only if the identity has a password hash.
  3. Continue with <Provider> for each linked upstream identity (SocialIdentity rows whose provider is configured on the auth server): Badger Sett, Google, Apple.

An identity with none of these (a magic-link-only account, say) sees an explanation telling them to add a passkey, not a dead end. An email one-time code would also cover them; it is not built yet.

The SSO challenge

POST /stepup/sso/start redirects to the provider's authorize endpoint with a fresh state, nonce and PKCE challenge, asking for fresh authentication (max_age=0, prompt=login). /stepup/sso/callback receives the answer.

It deliberately bypasses the login pipeline. The callback is a dedicated route on the stepup filter chain, which has no oauth2Login. It exchanges the code itself and validates the ID token itself, so a stepup can never sign anyone in, replace the main auth session, link an account, run the Sett migration or fire a success handler. It reads and writes only its own stepup-context attributes (STEPUP_CTX_*, STEPUP_SSO_*) on the auth session; the security context and the session id are never touched.

StepupSsoService checks, in order:

  1. Signature, aud/azp, exp, iat: Spring's OidcIdTokenDecoderFactory, against the provider's JWK set.
  2. The iss exactly (Spring only checks it when the registration carries an issuer URI, which Google and Apple don't). Known issuers are listed in the service.
  3. The nonce this attempt sent.
  4. Freshness (below).
  5. The subject: sub must equal SocialIdentity.providerSubject for that provider. A link made before subjects were recorded has none, and falls back to the token's verified email matching the identity's, which is the trust the normal login uses to find the account.

The controller owns the state: it is removed from the session before anything is compared, so a replayed or forged callback can never be matched, and an attempt older than ten minutes is refused. The stepup context itself expires after badger.auth.stepup.context-ttl-seconds (default 600).

The auth_time policy

Case Result
auth_time present Always enforced: within max-auth-age-seconds (default 300) of now, and not more than 60 s in the future
Absent, provider Badger Sett Rejected
Absent, provider Google Accepted only because we sent prompt=login, and only if the ID token's own iat is inside the same window
Absent, provider Apple Rejected

Reasoning: max_age=0 obliges a conforming provider to re-authenticate and return auth_time, so absence from a provider we asked this of is suspicious by default. Google honours prompt=login but does not reliably include auth_time, so for Google (and only where prompt=login was sent) the ID token's iat stands in. Apple accepts neither prompt nor max_age, so no re-authentication is requested of it (its ID tokens do document auth_time, and a recent one is accepted); with no prompt=login sent the fallback must not apply, however it is configured. The set of providers allowed to omit auth_time is badger.auth.stepup.sso.auth-time-optional-providers (default google).

Apple also gets response_mode=form_post (it requires it for the email scope) and no PKCE parameters; the callback accepts both GET and POST, is CSRF-exempt on the stepup chain (as /login/oauth2/code/apple is on the main one), and the state is its defence.

Registering the callback URI (ops)

The stepup callback is its own redirect URI, different from the login callback, and has to be registered with every provider you want to offer. It defaults to {badger.auth.issuer-url}/stepup/sso/callback; override with badger.auth.stepup.sso.callback-uri.

Provider Register Production Local dev
Google OAuth client → Authorized redirect URIs https://auth.bdgr.co.uk/stepup/sso/callback https://auth.bdgr.localhost/stepup/sso/callback
Apple Services ID → Sign in with Apple → Return URLs https://auth.bdgr.co.uk/stepup/sso/callback not possible (Apple rejects localhost)
Badger Sett The badger-auth client's redirect URI allowlist (exact match) https://auth.bdgr.co.uk/stepup/sso/callback https://auth.bdgr.localhost/stepup/sso/callback

Until a URI is registered the provider refuses the request and the user sees the "couldn't confirm your sign-in" message, with the other challenges still available. Also confirm that Badger Sett honours max_age/prompt=login and returns auth_time; the policy above rejects it if not.

The grace window

A proof is single use, but stepup is a full-page round trip, so a guard that demanded one per request would make multi-step edits unbearable. StepupReturnController therefore turns a verified proof into a grant: a cookie named BDGR_STEPUP_<purpose> holding a signed STEPUP token with method=grant, valid for badger.stepup.grace-seconds (default 300; values under 30 are raised to 30, because the grant also carries the user from the return redirect to their action).

  • Purpose isolation: one cookie per purpose, and the purpose is inside the signature. A manage-roles grant satisfies nothing else, even if the cookie is renamed.
  • Person-bound: the grant names the user; it only counts for that signed-in user.
  • Proof ≠ grant: a proof is rejected where a grant is expected and vice versa, so neither can stand in for the other. The proof's nonce is still spent when the grant is issued.
  • The cookie is HttpOnly, Secure, SameSite=Lax.

Why a cookie and not a web-session flag. The proof comes back as a cross-site POST from the auth server. The tenant's session and user cookies are SameSite=Lax, so the browser sends none of them on that request: there is no session to write to, and creating one would replace the real session cookie. A cookie set on that response is accepted and then sent on the redirect that follows.

Guarding an endpoint

@PostMapping("/users/{userId}/updateRoles")
@PreAuthorize("hasAnyRole('ADMIN', 'SUPERADMIN')")
@RequiresStepup(purpose = "manage-roles")
public String updateRoles(...) { ... }
  • Pick a purpose that names the class of action, 1-40 characters of A-Za-z0-9_-.
  • Don't read a password parameter; the interceptor has already done the checking.
  • Don't let a form be usable only to be bounced. A bounced POST can't be replayed (the user is sent back to the page that submitted it, taken from a same-origin Referer, else /), so the page should check for a grant first and offer the form only once it is satisfied:
model.addAttribute("rolesStepupSatisfied",
    stepupGrantService.isGranted(request, siteContext, getUser(), "manage-roles"));

and otherwise show a link to /stepup-launcher?purpose=manage-roles&return=<page>. The user details page does this for role editing: the Edit roles button becomes Verify it's you to edit roles until stepup is done, and the editor dialog opens straight away on return. If the grace window lapses while the dialog is open, the save is bounced back to the page and the user verifies again. * XHR callers get 401 with X-Stepup-Required and X-Stepup-Return; stepup.js (BadgerStepup.handleXhr) navigates for them.

Configuration

Property Default Where Meaning
badger.stepup.grace-seconds 300 web Grace-window length (minimum 30)
badger.auth.stepup.sso.callback-uri issuer URL + /stepup/sso/callback auth Redirect URI sent to providers
badger.auth.stepup.sso.max-auth-age-seconds 300 auth How recent auth_time must be
badger.auth.stepup.sso.auth-time-optional-providers google auth Providers allowed to omit auth_time (needs prompt=login)
badger.auth.stepup.context-ttl-seconds 600 auth How long a launched stepup stays answerable
badger.stepup.rate-limit.ip-per-minute / email-per-minute 10 / 5 auth Rate limits (per IP in the filter; per identity in the controller)

Audit and rate limiting

The audit.stepup logger records stepup.launched, stepup.sso-started, stepup.challenge-failed (with method, provider and a reason), stepup.rate-limited and stepup.issued. Lines name the person by AuthIdentity id and never carry an email, IP address, token or provider claim; provider error text is reduced to the standard OAuth error code. The IP filter covers every /stepup/** route, including the SSO ones; the per-identity limit is applied to the password check and to the SSO callback.

Follow-ups

  • Email one-time code, for identities with no passkey, password or linked provider.
  • TenantManagementController and SiteUserManagementController still prompt for passwords.
  • The grace cookie is not cleared on logout; it lapses within the grace window.