Handoff: BFF step-up cannot complete under response_mode=form_post¶
Use this prompt in the Authifi BFF repository. Observed against
ghcr.io/authifi/idbroker-tools/bff-gateway:3.1.0 while integrating Floh
LSA-9825. The RFC 9470 step-up feature added in 3.1.0 fails every time
under the image's own default response mode.
Summary¶
POST /bff/callback rejects every step-up completion with
invalid_reauthorization_state. The user completes MFA successfully; the
gateway then discards the result.
The reauthorization transaction is stored in the session cookie, which is
SameSite=Lax. The callback is a cross-site POST (response_mode=form_post),
and browsers do not send Lax cookies on cross-site POST. The transaction is
therefore never readable at the callback.
This is not environment-specific. With form_post — the library's default for
this configuration — step-up can never succeed.
Evidence¶
utils/reauthorization.js stores and reads the transaction on the session:
function createReauthorizationTransaction(req, sessionName, parsed, stepUp) {
// ...
const session = req[sessionName];
if (session) {
session[exports.STEP_UP_NAMESPACE] = transaction; // <- session cookie
}
req[exports.TRANSACTION_SYMBOL] = id;
return id;
}
function consumeReauthorizationTransaction(req, sessionName, expectedId, now, ttlSeconds) {
const session = req[sessionName];
if (!session) {
return { status: "absent" }; // <- always, on form_post
}
// ...
}
middlewares/bff-oidc.middleware.js turns absent into the observed error:
const result = consumeReauthorizationTransaction(
req,
sessionName,
transactionId,
now,
stepUp.stateTtlSeconds,
);
if (result.status === "absent" || result.status === "mismatched") {
throw new Error("invalid_reauthorization_state");
}
The library already knows form_post needs a relaxed SameSite, and applies
it to its own transaction cookie only
(@axleresearch/express-openid-connect@2.18.4, lib/context.js:400):
sameSite:
options.authorizationParams.response_mode === 'form_post'
? 'None'
: config.transactionCookie.sameSite,
The session cookie gets no equivalent escalation. lib/config.js:75 defaults
session.cookie.sameSite to 'Lax', and the image's production.json sets
only httpOnly and secure, so Lax is the shipped default.
Observed directly on GET /bff/login?popup=true&acr_values=mod-mf&max_age=300:
| Cookie | SameSite under form_post |
under query |
|---|---|---|
auth_verification (library transaction state) |
None |
Lax |
<session> (holds the step-up transaction) |
Lax |
Lax |
The contrast in the first column is the defect in one line: the only cookie escalated for the cross-site POST is the one the step-up feature does not use.
Consequences beyond the visible error¶
- The token exchange succeeds before the state check fails, so the authorization code is consumed and an access token is minted and discarded. Each retry burns a full round trip and, for a phishing-resistant authenticator, a real user gesture.
- Because the session cookie never arrives, the callback is processed as an unauthenticated request. Confirm that a failed step-up cannot displace or regenerate the caller's existing session.
- The failure is indistinguishable, from the browser, from "your MFA was rejected". Users read a successful passkey ceremony followed by "verification failed".
Required change¶
Do not store callback-critical state in the session cookie. Options, in descending preference:
- Carry the transaction in the login state. The transaction id already
round-trips through
getLoginState→ OAuthstate, and the library mirrorsstateinto theauth_verificationcookie, which is escalated toSameSite=Noneforform_postand is integrity-checked at the callback. Moving the transaction body (mode,popupSuccessPath,popupFailurePath,returnTo,acrValues,maxAge,createdAt) alongside the id removes the session dependency entirely. Note the fields are not secret, butstateis plaintext base64 in the URL — if any future field is sensitive, encrypt rather than reverting to the session. - Use a dedicated transaction cookie that applies the same
form_post ⇒ Nonerule the library applies toauth_verification.
Do not fix this by escalating the session cookie to SameSite=None. That
sends the session on every cross-site request and removes a defense-in-depth
layer from all consumers to serve one feature.
Interim workaround Floh is using¶
Setting auth.authorizationParams.response_mode = "query" makes the callback
a top-level GET, which Lax cookies do accompany, so the transaction is
found. The library registers the callback for GET
(middleware/auth.js:62) and PKCE (S256) is already in use, so the
authorization code is single-use and verifier-bound.
We consider this a workaround, not a resolution: the gateway's access log
records full query strings, so query mode writes authorization codes into
logs. That is a poor trade to have to make, and it is why we would rather have
the state moved than the response mode changed.
Tests required¶
- Full popup step-up round trip under
response_mode=form_postcompletes and returns a replacement token. This is the regression test — it fails on 3.1.0 today. - Same under
response_mode=query. - Session cookie remains
SameSite=Laxin both. - A failed or expired step-up leaves the caller's pre-existing session intact.
- A tampered transaction payload is rejected (covers option 1, where the payload leaves the server).
Acceptance handoff¶
Return the release version and immutable image digest, links to the tests above, and confirmation of which storage option was chosen. Floh pins exact versions and will re-run its portal step-up pass against the new image.
Stop conditions¶
Stop and report BLOCKED rather than improvising if:
- the transaction payload cannot be integrity-checked once it leaves the server, and no dedicated-cookie option is workable;
- fixing this requires the session cookie to become
SameSite=None.