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:
- Passkey, if any WebAuthn credential is registered.
- Password, only if the identity has a password hash.
- Continue with <Provider> for each linked upstream identity (
SocialIdentityrows 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:
- Signature,
aud/azp,exp,iat: Spring'sOidcIdTokenDecoderFactory, against the provider's JWK set. - The
issexactly (Spring only checks it when the registration carries an issuer URI, which Google and Apple don't). Known issuers are listed in the service. - The
noncethis attempt sent. - Freshness (below).
- The subject:
submust equalSocialIdentity.providerSubjectfor that provider. A link made before subjects were recorded has none, and falls back to the token's verifiedemailmatching 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 |
|---|---|---|---|
| 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-rolesgrant 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.
TenantManagementControllerandSiteUserManagementControllerstill prompt for passwords.- The grace cookie is not cleared on logout; it lapses within the grace window.