fix(mfa): bind the challenge to its tenant, and scope every MFA key by it - #141
Conversation
…y it The MFA temp token carried no tenant, so the challenge had none in scope and resolved its subject with `find_by_id(sub, None)` — by id, across every tenant. Everything downstream then ran on whatever row the repository returned: the status gate, `mfa_enabled`, the secret it decrypts, the recovery digests it scans, and the account the session is finally minted for. A library cannot assume the host's ids are unique across tenants — `find_by_id` takes a tenant precisely because they may not be — and under a schema that numbers users per tenant, every tenant has a user `1`. This is the same `find_by_id(sub, None)` class closed for the status gate in #139, still live on the challenge path because there was no tenant to pass. The token now carries `tenantId` on the dashboard arm. It is optional on the wire and mandatory in effect: a dashboard challenge token WITHOUT it is refused, and a platform token WITH one is refused too. Falling back to the unscoped shape would leave the vulnerable derivation reachable by omitting an optional field, which lets the attacker pick the old path. RFC 8725 §3.9 sets absent-means-reject for a missing audience, §3.12 asks for validation rules that reject a token of the wrong kind, and ASVS 5.0 6.6.2 requires the out-of-band token be bound to the authentication request that generated it. Eight key derivations were scoped by plane but not by tenant, not five: the transition lock, the pending-enrolment record, the recent-auth marker, the TOTP anti-replay marker, the recovery-code claim, and the challenge, disable and reauth failure counters. The three counters were the worst and it is not close — the other five cost a collision between two accounts sharing an id, while a shared counter is a CREDENTIAL-FREE CROSS-TENANT LOCKOUT: failures against one tenant's user spend another tenant's budget and a success on either side clears the other's. That is not the per-subscriber-account rate limiting NIST SP 800-63B requires. The contract's own `identifierPreimages` comment documents this exact bug one level up, where a tenant literally named `platform` locked an operator out of the console; that fix landed for the login counter and stopped there. All eight now derive from one `scoped_subject`, driven by the PLANE rather than by whether a tenant was supplied — so a platform caller that passes one cannot move the platform preimage off `platform:{userId}`, which is half of a cross-implementation agreement. `require_plane_tenant` refuses a dashboard operation with no tenant at the five public entry points, before any key is derived, because the core API is public and a host can call it directly. The contract pinned only two of the eight, so six derivations could drift with nothing to detect it — the same blind spot that let the OTP stored value move unnoticed. All eight are now named, under `mfaSubjectPreimages` (the single definition) and `mfaSubjectDerivedKeys`, and a test reads the file and compares it against what the code builds. `identifierPreimages` is untouched: it is the email-keyed `lf:` login preimage and it was already correct. Also re-anchors the `issue_mfa_temp_token` equivalent-mutant exclusion, which had been stale on `main` since before this change (the method sat at 1022, the anchor read 632). The stale anchor did surface both mutants as survivors, but the sweep runs post-merge, so a survivor is a report line and not a red PR — a broken anchor there does not fail closed, which is now written down beside it. Gates: fmt, clippy -D warnings, rustdoc -D warnings, full suite green, llvm-cov lines 100% / functions 100%, cargo-mutants in-diff 62 tested, 55 caught, 7 unviable, 0 survivors.
There was a problem hiding this comment.
Pull request overview
Binds MFA challenges and datastore keys to tenant-scoped identities, preventing cross-tenant account resolution and lockout collisions.
Changes:
- Adds tenant claims to MFA temporary tokens and validates plane/tenant combinations.
- Tenant-scopes MFA keys, counters, lookups, and related tests.
- Extends the wire contract and updates mutation-test configuration.
Reviewed changes
Copilot reviewed 16 out of 16 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
packages/rust-auth/src/shared/jwt-payload.types.ts |
Exposes the optional tenant claim. |
crates/bymax-auth-types/src/claims.rs |
Adds and tests MFA tenant serialization. |
crates/bymax-auth-jwt/src/keys.rs |
Updates JWT claim fixtures. |
crates/bymax-auth-jwt/src/hs256.rs |
Updates HS256 fixtures. |
crates/bymax-auth-core/src/services/token_manager.rs |
Issues and verifies tenant-bound challenges. |
crates/bymax-auth-core/src/services/platform.rs |
Issues tenantless platform challenges. |
crates/bymax-auth-core/src/services/oauth.rs |
Uses the authenticated OAuth account tenant. |
crates/bymax-auth-core/src/services/mfa/tests.rs |
Adds tenant-isolation and contract tests. |
crates/bymax-auth-core/src/services/mfa/setup.rs |
Tenant-scopes MFA enrollment. |
crates/bymax-auth-core/src/services/mfa/mod.rs |
Centralizes tenant-scoped MFA key derivation. |
crates/bymax-auth-core/src/services/mfa/manage.rs |
Tenant-scopes management operations. |
crates/bymax-auth-core/src/services/mfa/challenge.rs |
Resolves and processes tenant-bound challenges. |
crates/bymax-auth-core/src/services/auth/login.rs |
Adds the authenticated tenant to challenges. |
conformance/wire-contract.json |
Defines shared MFA key preimages. |
bindings/bymax-auth-wasm/src/jwt_edge.rs |
Updates WASM claim fixtures. |
.cargo/mutants.toml |
Re-anchors the token issuer exclusion. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…tenant at issuance Addresses the four review findings on #141. All were real; none dismissed. Scoping the READ was not enough. `UserRepository::update_mfa` carried no tenant, so the write every MFA transition performs could not be scoped: under a schema that numbers users per tenant the update lands on another tenant's row, or the row this flow read is not the row it wrote and a recovery code spent by a successful challenge is never spliced out. The trait now takes `tenant_id`, threaded from `transition_mfa_record` through `persist_mfa`, and the in-memory fixture enforces it on writes so a test cannot pass against a fixture laxer than the contract. The existing single-use test did NOT catch that. A second challenge with the same code is refused by the `rcu:` claim, planted before the splice and living 300 s, so the code reads as spent for the whole window whether or not the write landed — and comes back once the claim lapses. The new regression asserts the PERSISTED digest list shrinks by one in the tenant's own row, which is what distinguishes "refused by the claim" from "actually spent" (ASVS 5.0 6.5.1). Passing `tenant_id` to the repository is a request, not an enforcement: the repository is the host's, and a single-tenant host whose `find_by_id` ignores its second argument is the shape nobody notices — precisely the deployment where ids collide. `login` already refuses such an answer; the challenge is the other door into the same account and had no such check. It now warns and filters, covered by a deliberately non-compliant fixture repository. Issuance accepted shapes verification rejects, so a host calling the public core API received a signed credential — with a planted `mfa:` marker — that could never be redeemed, surfacing one round-trip later as an opaque invalid token. Both now consult one predicate, `plane_tenant_is_well_formed`, which also refuses `Some("")`: a blank tenant would build `dashboard::{userId}`, a third keyspace neither implementation derives, and a blank string is what an unset environment variable looks like by the time it reaches a call site. The errors differ deliberately — a caller ASKING for a malformed challenge gets `auth.validation` naming the field, one PRESENTING it gets the same opaque refusal every bad temp token gets. That guard immediately caught a fixture passing `Some("t1")` on the platform plane, which had always "worked" because nothing checked the pair. One coverage note worth keeping: a `tracing` field on its own line is only evaluated when a subscriber is listening, so it reads as uncovered in every run that does not install one. Bound before the macro instead, with the reason at the call site. Gates: fmt, clippy -D warnings, rustdoc -D warnings, full suite green, llvm-cov lines 100% / functions 100%, cargo-mutants in-diff 71 tested, 64 caught, 7 unviable, 0 survivors. The cross-tenant and splice tests were red-checked.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 22 out of 22 changed files in this pull request and generated 1 comment.
Suppressed comments (5)
crates/bymax-auth-core/src/services/mfa/tests.rs:3014
- This only checks that each contract entry is a string; any formula is accepted. A typo or peer-contract change such as dropping the
challenge:namespace therefore leaves Rust's derivation unchanged while this drift detector stays green. Compare every contract formula with an independently constructed value from the corresponding Rust helper, rather than checking presence alone.
assert!(
derived
.get(key)
.and_then(serde_json::Value::as_str)
.is_some(),
crates/bymax-auth-core/src/services/mfa/mod.rs:247
- This new
Validationreturn changes the documented error contract of all five public callers, but their# Errorssections still omit it (setup.rs:18-22,100-103andmanage.rs:18-22,85-89,130-134). Document malformed plane/tenant pairs so API consumers can handle the new error.
Err(AuthError::Validation {
details: vec![bymax_auth_types::FieldError {
field: "tenantId".to_owned(),
message: "a dashboard MFA operation requires a non-empty tenantId, and a platform \
one requires none"
.to_owned(),
}],
crates/bymax-auth-core/src/traits/repository.rs:75
- The public trait now requires
tenant_id, but the implementer example inREADME.md:212and the trait definition indocs/technical_specification.md:1484still show the old two-argument method. Copied integrations no longer compile and the specification omits the required tenant-scoped write; update both references with this signature.
async fn update_mfa(
&self,
id: &str,
tenant_id: Option<&str>,
data: UpdateMfaData,
) -> Result<(), RepositoryError>;
packages/rust-auth/src/shared/jwt-payload.types.ts:127
- Adding this claim makes the public
getTenantIddocumentation inaccurate:packages/rust-auth/src/nextjs/jwt.ts:224still promisesundefinedfor every MFA-temp token, while the helper now returns this value for dashboard challenges. Update that return contract to distinguish dashboard and platform MFA-temp tokens.
* Optional in the wire format so the claim is additive, but a dashboard challenge token
* WITHOUT it is refused at verification rather than falling back to the unscoped shape —
* a fallback would leave the vulnerable derivation reachable by simply omitting a field.
*/
tenantId?: string,
crates/bymax-auth-core/src/services/token_manager.rs:1066
- This public method now returns
AuthError::Validationfor a malformed plane/tenant pair, but its# Errorssection still lists only signing and store failures. Add the validation case so callers are not surprised by an undocumented error variant.
tenant_id: Option<&str>,
| match (ctx, tenant_id) { | ||
| (MfaContext::Dashboard, Some(tenant)) => { | ||
| format!("{}:{tenant}:{user_id}", ctx.as_str()) | ||
| } | ||
| _ => format!("{}:{user_id}", ctx.as_str()), |
What was wrong
The MFA temp token carried no tenant, so the challenge had none in scope and resolved its subject with
find_by_id(sub, None)— by id, across every tenant. Everything downstream then ran on whatever row the repository returned:assert_not_blocked) — evaluated against the wrong accountmfa_enabled, the encrypted secret it decrypts, the recovery-code digests it scansA library cannot assume the host's ids are unique across tenants —
find_by_idtakes a tenant precisely because they may not be — and under a schema that numbers users per tenant, every tenant has a user1. This is the samefind_by_id(sub, None)class closed for the status gate in #139, still live on the challenge path because there was no tenant to pass.Eight preimages, not five — and the counters are the worst
Eight key derivations were scoped by plane but not by tenant:
mfalock:mfa_setup:ra:tu:rcu:lf:×3The three counters are the worst and it is not close. The other five cost a collision between two accounts that happen to share an id. A shared counter is a credential-free cross-tenant lockout: failures against tenant A's user spend tenant B's budget, and a success on either side clears the other's. That is not the per-subscriber-account rate limiting NIST SP 800-63B requires, nor the per-tenant isolation the OWASP Multi-Tenant Security Cheat Sheet asks of any key in a shared datastore.
The contract's own
identifierPreimagescomment documents this exact bug one level up — a tenant literally namedplatformcollided with the platform plane and locked an operator out of the console. That fix landed for the login counter and stopped there.Refuse, don't fall back
tenantIdis optional on the wire and mandatory in effect. A dashboard challenge token without it is refused; a platform token with one is refused too. A fallback would leave the vulnerable derivation reachable by omitting an optional field — the attacker picks the old path.MUST rejectDesign notes
scoped_subject, driven by the plane rather than by whether a tenant was supplied — so a platform caller that passes one cannot move the platform preimage offplatform:{userId}, which is half of a cross-implementation agreement with nest-auth. Pinned byscoped_subject(Platform, Some("t1"), u) == scoped_subject(Platform, None, u).require_plane_tenantrefuses a dashboard operation with no tenant at the five public entry points, before any key is derived — the core API is public and a host can call it directly without going through the axum layer.Contract
Six of the eight derivations were unpinned, so they could drift with nothing to detect it — the same blind spot that let the OTP stored value move unnoticed. All eight are now named under
mfaSubjectPreimages(the single definition) andmfaSubjectDerivedKeys, with a test that reads the file and compares it against what the code builds.identifierPreimagesis untouched — it is the email-keyedlf:login preimage and was already correct. It looked like it covered the three counters because they share thelf:prefix; they do not.Mutation anchor
Re-anchors the
issue_mfa_temp_tokenequivalent-mutant exclusion, which had been stale onmainsince before this change (the method sat at 1022, the anchor read 632). The stale anchor did surface both mutants as survivors — but the sweep runs post-merge, so a survivor is a report line and not a red PR. A broken anchor there does not fail closed, which is now written down beside it.Gates
cargo fmt --check,cargo clippy -D warnings,RUSTDOCFLAGS="-D warnings" cargo doc— cleancargo llvm-cov— lines 100%, functions 100%cargo mutants --in-diff— 62 tested, 55 caught, 7 unviable, 0 survivorsfind_by_id(sub, tenant)toNoneturns that test red and only that oneCross-repo
nest-auth mirrors the same gaps and is implementing them in parallel; the contract text here is what that side mirrors byte-for-byte. rust-auth is unpublished with no consumer, so it takes a hard cutover with no migration machinery — nest-auth, with 17 published versions, needs dual-write on
tu:/the counters and dual-acquire onmfalock:for one release. Byte-identity is required on the final format, not on the migration path.