fix(security): close the second audit's findings, and align the credential path with nest-auth - #131
Conversation
…rmat by vector
The second cross-implementation audit found that the two libraries cannot
verify each other's stored password hashes. nest-auth wrote
`scrypt:N:r:p:{saltHex}:{derivedHex}`; this crate writes and reads PHC.
`PasswordHash::new` rejects the colon encoding, and because `verify` is total
the rejection collapses to `Ok(false)` — so the engine answers
`auth.invalid_credentials`, indistinguishable from a wrong password. Five of
those trip the `lf:` brute-force counter, which the two backends key
identically, and the account is locked out of both by its owner's own correct
password.
The module docs already claimed "a compatibility parser" and the technical
specification described a shim that parses the legacy format and treats it as
needing a rehash. Neither existed.
`legacy.rs` adds the read path: `verify_phc` falls back to it when
`PasswordHash::new` fails, deriving under the parameters the record carries and
comparing with `subtle::ConstantTimeEq`. Nothing mints the shape, and
`needs_rehash` already reports it stale, so a stored corpus migrates on each
owner's next successful sign-in.
`credentialFormats.passwordHash` read "self-describing: the parameters the hash
was written under travel with it" — which BOTH encodings satisfy. That is why
the divergence survived a release: prose each side could satisfy alone, with
neither suite testing against the other's output. It is replaced by a
`passwordHashFormat` section pinning the encoding, the B64 alphabet, the
parameter-lookup rule, the accepted derived-key range, and the staleness
triggers, with three known-answer vectors — one written by each implementation
and one legacy. Every string in it is real emitted output. Both suites verify
all three, so a drift in either encoder, parser, alphabet or parameter ordering
turns that side red.
nest-auth writes PHC as of the paired change and keeps a mirror-image read path
for this crate's output, so the compatibility is bidirectional. Its 64-byte
derived key and this crate's 32-byte one both verify here: the length travels
with the hash, and it is deliberately not a staleness trigger, or every hash
would rehash on every crossing of a shared user table and never converge.
The contract file stays byte-identical between the two repositories.
…sswords Two findings from the second cross-implementation audit. **`oauth_initiate` was the one flow still reading the caller's tenant verbatim.** Login, register, all four password-reset steps and both email verification steps open with `resolve_tenant`; this one took `?tenantId=` straight from the query string and wrote it into the single-use `os:` state record, which the callback then reads back for `find_by_oauth_id`, `CreateWithOAuthData` and the `HookContext`. It is also the worst flow to leave open, because it is the one that decides which tenant an account is *provisioned into*. Against a deployment that configures a `TenantIdResolver` — host or subdomain tenancy, the reason the resolver exists — an unauthenticated caller could name any tenant, complete a normal sign-in with their OWN provider account, and be created or linked inside it. The doc comment's rationale, that `on_oauth_login` enforces tenant membership, did not hold: the hook is handed the same spoofed value through its `HookContext`, so a hook deciding on the profile alone admitted it. nest-auth closed this on its side and its comment names the stakes exactly — "the one door that still took it verbatim... strictly more than the others were protecting". This side never got the fix, which is the drift the parity work exists to end. The resolved value is what goes into the state, so the callback cannot be talked into a different one either. The regression test lives beside the other OAuth tests because it needs a configured provider to reach the resolver at all, with a pointer left in `every_tenant_scoped_flow_honours_the_resolver` — the checklist where a missing flow is a flow nobody notices. **The default breach screen refused every non-Latin password.** `reduce_to_base_word` filtered to `is_ascii_alphanumeric`, so a password in Cyrillic, Han, Kana, Hangul, Greek, Arabic, Hebrew or Thai reduced to the empty string — below `MIN_BASE_LENGTH`, which `is_breached` answers `true` for. Users of those scripts were refused on register, on reset and on change, and told their strong password was commonly used, which pushes a whole class of users toward the strictly smaller ASCII keyspace. That inverts the purpose of a breach screen. The filter now keeps letters and numbers in any script, and the floor counts characters rather than UTF-8 bytes. The weak ASCII shapes the floor exists for are still refused, and a repeated non-ASCII character is now caught by the repetition rule rather than by collapsing to "" — the same answer, reached for a reason that keeps holding when the script changes. Keeping the characters also makes a consumer's non-Latin extra word reachable, since extras are normalized through the same function and previously all became "". nest-auth carried the identical defect through a different pair of ASCII filters and is fixed in the same round.
…t in the logs
**Four routes carried the wrong limit or none.** `POST /auth/platform/logout`
and `DELETE /auth/platform/sessions` were mounted with no throttle at all, and
both `recovery-codes` routes were served under `mfa_setup` (5/60) instead of
`mfa_disable` (3/300) — 25x more permissive than the sibling backend on a route
that is TOTP-gated and can repeatedly invalidate a victim's recovery codes.
nest-auth states the intent this diverged from: regeneration shares the disable
throttle "because the security posture is identical".
`/auth/platform/logout` is the one that mattered. It is public by deliberate
design — a caller whose access token expired must still be able to kill their
refresh session — which is exactly why it needs a limit. Unauthenticated and
unthrottled, a loop of invented 64-hex strings drives `find_session`, an HMAC
verify, `revoke_session` and `delete_grace_pointer`: two to four round trips
each holding one of the pool's connections, against a pool with no wait timeout.
Nothing could catch this class. All 27 limit VALUES are pinned against the
shared contract and the contract is checked for extra names, but no test
asserted which limit each ROUTE wears — wiring `login` to `register`'s 10/3600
would have passed the entire suite. The new test bottles one named limit down to
a single request at a time and asserts what 429s under it, with a negative case
so an assertion cannot pass for a route wired to either name.
Writing that test surfaced two ways it could have been vacuously green: the
whole `adapter.rs` file is gated on every optional feature, so `cargo test
--test adapter` without `--all-features` compiles zero tests and exits 0; and
the default `EngineSpec` mounts no platform group, so the first version asserted
against a 404. The helper now refuses a 404 explicitly.
**Reuse detection and failed logins were anonymous.** Refresh-token reuse is
the strongest compromise signal the library produces, and it was logged as bare
prose — the account reached only a consumer who had wired
`on_refresh_token_reuse_detected`, and the shipped hooks are no-ops. Both planes
now name the family on detection and the owner on revocation, as two events, so
a `revoke_family` that fails cannot take the finding down with it.
`login: invalid credentials` logged neither the address nor the tenant, with
both in scope as parameters of the enclosing function. nest-auth logs a masked
address and a sanitized tenant; this side now matches.
**`log_safe`, and the `tenant_id` charset.** `tenant_id` arrives in the body of
five public routes and is the caller's own value whenever no `TenantIdResolver`
is configured — the default — then reaches a tracing event, a Redis key segment
and an HMAC preimage. nest-auth has always rejected control characters in it;
this side accepted them, so the same request was a 400 on one backend and a 200
on the other, which is precisely what `requestFieldBounds` exists to prevent.
It also let a caller forge a record in a plain-text log pipeline (ASVS 16.4.1).
All nine `tenant_id` fields now carry the check, and `log_safe` is the second
lock at the log site for values that reach one without passing a DTO.
**The session index no longer grows a permanent member per refresh.** A rotation
adds `rp:{old}` alongside `rt:{new}`, and only a full revoke-all ever removed
it, while `refresh_rotate` re-arms the set's TTL each time. Every reader is
linear in the set's size, including `invalidate_user_sessions`, which iterates
it inside a script and blocks the whole store. `list_sessions_inner` now drops
the members whose pointer has already expired, in the walk it was doing anyway;
pointers still inside their window are left alone. Not pruned from the rotation
path, which is hot — session-cap enforcement lists, so a login bounds it.
nest-auth carries the identical fix.
The non-Latin fix changed the default screen from refusing every non-Latin password to admitting every one of them. Neither end is the whole story: the reduction now preserves letters in any script, but the shipped base words hold no entries in those scripts, so the equivalent of `password` in one of them passes. That is a limitation worth naming rather than leaving for a deployment to discover — and it has a remedy, since extra words are normalized through the same reduction, so a non-Latin entry matches a decorated form of itself exactly as an ASCII one does. Held in step with nest-auth's copy.
…ly encoding Reverts the reader added earlier in this branch, and finishes the cleanup 6da0382 started. Both libraries are new and have never backed a deployment, so there is no corpus in nest-auth's pre-PHC `scrypt:N:r:p:{saltHex}:{derivedHex}` shape — and a reader for it is an unused branch in the credential-verification core, which is exactly the reasoning 6da0382 gave when it removed the compatibility paths in the first place. What that earlier commit left behind, and this one clears: - `legacy.rs` and the `verify_phc` fallback that reached it. - Two `exclude_re` patterns in `.cargo/mutants.toml` describing `is_legacy` and a `decode_hex` built on `(hi << 4) | lo` — both part of the reader 6da0382 deleted, and neither matching a mutant since. A stale exclusion is worse than none: it silently suppresses a future function that happens to share the name, and nobody decided that. - The specification's claim that "a compatibility shim parses that legacy colon-delimited format", which described the deleted shim rather than the code. nest-auth writes PHC as of the paired change, so a hash from either backend verifies under the other and nothing in the credential path branches on which library wrote the record. The contract keeps two vectors, one per implementation, and its comment records why there is no third. Correcting my own earlier commit message: 098aaf6 said the documented shim "never existed". It did — it was removed deliberately on 2026-07-29, and only the documentation and the mutation-config exclusions outlived it. Verified: workspace lib tests, `clippy -D warnings --workspace --all-targets --all-features`, the adapter integration tier, and `cargo fmt --check` all clean.
… a suppression, a stale doc
Five findings from the code and security reviews of this branch. Each was
verified against the code before being accepted; three are corrections to things
this branch itself introduced.
**`log_safe` was applied at one tracing site out of ten.** The helper exists as
the second lock — the DTO rejects control characters at the boundary, and this
catches a value that reached a log line without passing one, which is exactly
what a host-supplied `TenantIdResolver` returns. Its own doc comment says so. But
only `record_failure_and_reject` used it; `login: account locked`, `login: MFA
challenge issued`, `login: success`, `lockout cleared`, `register`, `verify
email` and the three invitation events all interpolated `%tenant_id` raw. All ten
go through it now.
**The DEL clause was dead and its comment was wrong.** `log_safe` and
`no_control_characters` both read `c.is_control() || c == '\u{7f}'`, with a
comment claiming `is_control` covers C0 and C1 "but not DEL, which is named
explicitly". It does cover DEL — Unicode category Cc includes U+007F, confirmed
by running it. The clause was unreachable and the comment was a false statement
about the standard library. Both are gone, and the rule is stated as what it is.
**A `#[allow(clippy::type_complexity)]` this branch added.** A suppression is
itself a finding under the workspace rules, and this one was avoidable: a
`LimitSetter` type alias removes the complexity the lint was pointing at rather
than hiding it. The workspace is back to zero `#[allow]`/`#[expect]`.
**Two pieces of new security code had no test at all.** `no_control_characters`
is wired onto all nine `tenant_id` fields and nothing exercised it; the
grace-pointer pruning in `list_sessions_inner` was the one change motivated by an
unbounded-growth finding and nothing exercised that either. Both have tests that
fail when the code is reverted. The pruning test runs against real Redis and lets
the short pointer expire rather than deleting it — expiry is how that absence
actually arises — and asserts both halves: the dead member is dropped and the
LIVE one survives, since that member is what lets a revoke-all also kill a token
rotated away moments earlier.
**The specification still described the legacy reader.** That paragraph was
rewritten once before the decision to drop compatibility entirely, and not
revisited after. It now says what the code does: PHC is the only encoding either
implementation reads, a hash either parses as PHC or is refused, and there is no
compatibility path because neither library has ever backed a deployment.
Verified: `cargo fmt --check`, `clippy -D warnings --workspace --all-targets
--all-features`, workspace lib tests, the adapter tier and the real-Redis
`redis_stores` tier all clean.
…ot hold `OAuthInitiateQuery::tenant_id` still read "Not validated against the DB here (the `on_oauth_login` hook enforces tenant membership)" — the exact reasoning this branch's own `oauth_initiate` fix records as false. The hook is handed the same value through its `HookContext`, so a hook deciding on the profile alone admitted a caller into any tenant they named, on the one flow that decides which tenant an account is provisioned into. The field is a request for a tenant, not a decision about one, and the doc says so now: the resolver runs before anything is minted and the resolved value is what reaches the state record.
There was a problem hiding this comment.
🟡 Changes recommended
One modified tracing call contradicts its own “single-line for coverage” constraint, risking reintroducing the exact 100%-coverage false-negative it warns about.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Pull request overview
Closes multiple findings from the second cross-implementation security audit by aligning rust-auth’s tenant handling, rate-limit wiring, credential-format contract, logging safety, and Redis session index hygiene with the shared wire contract and the NestJS sibling.
Changes:
- Resolve OAuth tenant via
TenantIdResolver(and pass request context through adapters) so caller-providedtenantIdcannot select a provisioning tenant. - Pin password-hash encoding to PHC via shared contract vectors; improve breach-base reduction to support non-ASCII scripts and correct character-count semantics.
- Fix rate-limit wiring for specific routes; harden tenant-id validation and log safety; prune expired grace-pointer members during session listing.
File summaries
| File | Description |
|---|---|
| docs/technical_specification.md | Updates credential-format specification text to reflect PHC-only contract. |
| crates/bymax-auth-redis/tests/redis_stores.rs | Adds regression test for pruning expired grace-pointer members on session listing. |
| crates/bymax-auth-redis/src/stores/session.rs | Prunes expired rp: members during list_sessions to prevent unbounded session index growth. |
| crates/bymax-auth-crypto/src/password/tests.rs | Adds contract-driven PHC verification vectors test (reads shared wire-contract JSON). |
| crates/bymax-auth-crypto/src/password/phc.rs | Documents PHC as the only supported encoding across implementations. |
| crates/bymax-auth-crypto/src/password/mod.rs | Removes stale mention of a legacy compatibility parser from module docs. |
| crates/bymax-auth-crypto/Cargo.toml | Adds serde_json dev-dependency for contract-vector tests. |
| crates/bymax-auth-core/src/traits/common_password.rs | Preserves alphanumerics from any script and counts base length by characters (not UTF-8 bytes); adds tests. |
| crates/bymax-auth-core/src/services/token_manager.rs | Improves refresh-token reuse detection logging with family/user attribution. |
| crates/bymax-auth-core/src/services/oauth.rs | Resolves tenant before minting OAuth state; updates OAuth tests and adds resolver-honoring test. |
| crates/bymax-auth-core/src/services/auth/register.rs | Sanitizes logged tenant_id via log_safe. |
| crates/bymax-auth-core/src/services/auth/mod.rs | Notes OAuth initiate tenant-resolver coverage location in tests. |
| crates/bymax-auth-core/src/services/auth/login.rs | Sanitizes logged tenant_id via log_safe and expands invalid-credentials logging context. |
| crates/bymax-auth-core/src/services/auth/invitation.rs | Sanitizes logged tenant_id via log_safe. |
| crates/bymax-auth-core/src/services/auth/email_verification.rs | Sanitizes logged tenant_id via log_safe. |
| crates/bymax-auth-core/src/normalize.rs | Introduces log_safe helper to prevent control-character log forging. |
| crates/bymax-auth-core/src/lib.rs | Re-exports log_safe from the core crate surface. |
| crates/bymax-auth-axum/tests/adapter.rs | Adds tests asserting each route is wired to the intended named rate limit. |
| crates/bymax-auth-axum/src/routes/platform.rs | Applies throttling to POST /platform/logout and DELETE /platform/sessions per intended limits. |
| crates/bymax-auth-axum/src/routes/platform_mfa.rs | Corrects recovery-codes throttling to mfa_disable for platform MFA route. |
| crates/bymax-auth-axum/src/routes/oauth.rs | Passes request context into engine OAuth initiate so tenant resolver can be consulted. |
| crates/bymax-auth-axum/src/routes/mfa.rs | Corrects recovery-codes throttling to mfa_disable for dashboard MFA route. |
| crates/bymax-auth-axum/src/dto.rs | Adds tenant_id control-character rejection across DTOs + validation tests; updates OAuth initiate query docs. |
| conformance/wire-contract.json | Adds passwordHashFormat section with PHC vectors and updates password-hash description. |
| Cargo.lock | Locks new serde_json dev-dependency. |
| .cargo/mutants.toml | Removes stale mutation excludes tied to deleted legacy credential reader. |
Review details
- Files reviewed: 25/26 changed files
- Comments generated: 1
- Review effort level: Lite
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
{
"consecutiveFailures": {
"rustdoc:broken-intra-doc-links": 1,
"core / coverage (llvm-cov):fail-under-lines": 1
},
"flakyReruns": {
"core / clippy": 2,
"core / msrv": 1,
"analyze (javascript-typescript)": 1
},
"processedCommentIds": [3727983755],
"processedReviewIds": [4873636396, 4875365715, 4875938659],
"paused": false,
"localGate": "cargo llvm-cov --workspace --all-features --locked --fail-under-lines 100 --fail-under-functions 100",
"lastFixCommit": "ba588aa",
"lastLocalGate": "pass — 26218/26218 lines, 2945/2945 functions; fmt, workspace clippy, core lib 531/531; pre-push ran the full workspace suite clean",
"terminated": "2026-08-06T15:30:00Z",
"terminationReason": "green on ba588aa — 25 checks pass, mutation skipped by design on PRs, mergeable, the single Copilot thread resolved, all three review bodies triaged",
"flakyEvidence": "GitHub Actions outage 15:08-15:23Z: 'Failed to resolve action download info. Internal Server Error / Service Unavailable' during Set up job. No code ran. msrv and analyze(js-ts) recovered on rerun 1; core / clippy needed rerun 2 and then genuinely executed 'cargo clippy --workspace --all-targets --all-features --locked -- -D warnings' and passed.",
"note": "Copilot posts findings as SUPPRESSED comments in the review body, with no inline thread. Phase 2 MUST read pulls/<N>/reviews bodies, not only reviewThreads — body-only findings were missed twice (on f93b95c and 2201573).",
"copilotReviewStale": "IMPORTANT: the copilot-pull-request-reviewer run for ba588aa (31114374091) died in the same outage and reports 'cannot be retried'. The newest Copilot review covers 2201573, not the current head. Not a PR check, so it never blocked — but nothing has reviewed the register.rs change on the head. Re-request a Copilot review if you want head coverage.",
"openForAuthor": "Copilot review 4875938659 says 'Human review recommended' for the security-critical surfaces this PR touches (OAuth tenancy, password hash contract, rate limiting, Redis session semantics). Not a bot's call to clear."
} |
… broke
Two red checks, both real, both introduced by this branch.
**`rustdoc`.** `oauth_initiate`'s doc comment linked `crate::traits::TenantIdResolver`.
The trait is in `crate::config`; `traits` has no such item, so
`rustdoc::broken_intra_doc_links` failed the build under `-D warnings`. Linked where
the trait actually lives, as `services::auth::resolve_tenant` already does.
**`core / coverage`.** Eighteen lines uncovered against `--fail-under-lines 100`.
Fourteen of them are `tracing` field expressions that carry a call. A field
expression is not evaluated without an installed subscriber, so a call sitting on its
own line reads as uncovered while the branch around it is perfectly exercised. The
previous commit applied `log_safe` to nine `tenant_id` fields, which pushed every one
of those macro calls past `max_width` and rustfmt split them — the exact hazard the
comment above `login: account locked` warned about, which Copilot flagged as now
stale. The comment was right and is kept, at the one site that explains it; the calls
move into bindings ahead of the macro, which is width-independent, unlike keeping the
macro on one line. A field with no call (`%family`, `%revoker_user_id`) never gets a
region, so those stay inline.
`log_safe`'s rejection branch had only a doctest, which llvm-cov does not measure. It
has a unit test now, covering every category the rule names (C0, DEL, C1) and the two
it deliberately excludes (U+2028/U+2029).
The remaining three were shape, not substance:
- The tenant-resolver OAuth test bound its engine through a multi-line
`let ... else { return }`, so the never-taken arm became a LINE rather than a
region. Bound on one line, as `rustfmt.toml` documents.
- The same test's first assertion awaited inside a `matches!` scrutinee, which leaves
the resumption region on a line of its own. Awaited into a binding first, matching
the assertion ten lines below it.
- `list_sessions`' grace-pointer prune had no case where the member was neither a live
session nor a pointer. A legacy bare-hash member is exactly that, and it must be
left alone — the prune keys on an expired `rp:` key and a bare hash has none. The
pruning test now plants one and asserts it survives.
The redaction itself was never asserted anywhere. `log_capture` exists so a log line
that is a branch's only observable effect can be tested, and six sites now use it: the
masked address and the sanitized tenant reach `login: success`, `login: MFA challenge
issued`, `login: invalid credentials`, `login: account locked`, `lockout cleared`,
`verify email`, `invitation: created`/`withdrawn`/`revoke refused`, and both
reuse-detection warnings, which name the revoked account nowhere else.
Verified: `cargo fmt --check`, `clippy -D warnings --workspace --all-targets
--all-features`, `RUSTDOCFLAGS=-D warnings cargo doc --workspace --all-features`, and
the CI coverage command itself — `cargo llvm-cov --workspace --all-features --locked
--fail-under-lines 100 --fail-under-functions 100` — at 26217/26217 lines and
2945/2945 functions against a real Redis.
There was a problem hiding this comment.
🟡 Changes recommended
A test helper claims to widen every RateLimitConfig field but currently only covers a subset, reducing correctness and future-proofing of the new route↔limit wiring assertions.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
This review doesn't count toward merge requirements. Sign up for the private preview to control whether Copilot approvals count.
Review details
Suppressed comments (1)
crates/bymax-auth-axum/tests/adapter.rs:3461
ALL_LIMIT_SETTERSis documented as covering every field ofRateLimitConfig, but it currently only sets a small subset (e.g., it omitsforgot_password,oauth_initiate,ws_ticket, etc.). That makesrouter_with_one_narrow_limitless future-proof than the comment claims, and it can allow an unrelated default limit to trip if this probe is ever extended to hit additional routes.
Consider including every RateLimitConfig field in ALL_LIMIT_SETTERS so the helper actually widens all limits as intended.
const ALL_LIMIT_SETTERS: &[LimitSetter] = &[
|c, v| c.login = v,
|c, v| c.register = v,
|c, v| c.refresh = v,
|c, v| c.logout = v,
- Files reviewed: 26/27 changed files
- Comments generated: 0 new
- Review effort level: Lite
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
… to widen `ALL_LIMIT_SETTERS` is documented as "every field of `RateLimitConfig` … so a NEW limit added to the struct shows up here rather than being silently left at its default during a probe". It listed 8 of the struct's 27 fields, so the guarantee it states was not one it kept. Nothing is mis-asserted today: `router_with_one_narrow_limit` is used for routes wired to `logout`, `revoke_all_sessions`, `mfa_disable` and `mfa_setup`, and all four were in the list. The hazard is the next route added to the probe — an unrelated default limit trips, and the failure is attributed to the wiring under test. The negative assertion is the one that fails quietly: it reads "recovery-codes must NOT be served under `mfa_setup`", and a default limit tripping somewhere else would satisfy it for the wrong reason. All 27 fields are listed now, in declaration order so a field added between two of them is visibly missing rather than lost off the end of an unordered list. None are feature-gated, so the list needs no `cfg`. Verified: `cargo fmt --check`, `clippy -D warnings -p bymax-auth-axum --all-targets --all-features`, and the adapter tier — 75 passed, including `each_route_is_served_under_the_limit_it_declares`.
|
Re: Copilot review of 2026-08-06 13:55Z — Replying here rather than on a thread: that finding arrived as a suppressed comment in the review body, so it has no inline thread to answer on. Applied in 2201573. The finding is correct — verified against the code rather than taken on faith:
On impact, to be precise about what was and was not broken: nothing is mis-asserted today. All 27 fields are listed now, in declaration order, so a field added between two of them is visibly missing rather than lost off the end of an unordered list. None of the fields are feature-gated, so no Verified: |
There was a problem hiding this comment.
🔵 Human review recommended
It changes multiple security-critical auth surfaces (OAuth tenancy, password hash contract, rate limiting, Redis session semantics) and should receive final human verification despite strong tests.
Review details
Suppressed comments (1)
crates/bymax-auth-core/src/services/auth/register.rs:84
log_safe(&tenant_id)is called inside thetracing::info!field expression here, which is the pattern that previously caused coverage-gate false negatives when rustfmt wraps fields onto their own lines (and also makes the sanitization harder to assert unless a subscriber is installed). To make this robust and consistent with the other logging sites, bind the sanitized tenant first and log the binding.
let safe = SafeAuthUser::from(user);
tracing::info!(user_id = %safe.id, tenant_id = %log_safe(&tenant_id), "register: user registered");
let result = self
- Files reviewed: 26/27 changed files
- Comments generated: 0 new
- Review effort level: Lite
We're testing this review assessment. Please use 👍 or 👎 to tell us if it's correct.
The one `log_safe` call this branch left inside a `tracing` field expression. It is covered today only because the line happens to fit; the previous commit moved nine sibling sites into bindings precisely because a field expression is not evaluated without an installed subscriber, so the call reads as an uncovered line the moment rustfmt wraps it. Leaving one site on the old shape keeps the hazard alive and makes the rule harder to see. Behaviour is unchanged: the value is the same, computed one statement earlier. Not touched: the `mask_email` fields in `traits/email.rs` and `services/platform.rs` are on the same shape but predate this branch and are outside its diff. Verified: `cargo fmt --check`, `clippy -D warnings -p bymax-auth-core --all-targets --all-features`, the core lib tier (531 passed), and the CI coverage command — `cargo llvm-cov --workspace --all-features --locked --fail-under-lines 100 --fail-under-functions 100` — at 26218/26218 lines and 2945/2945 functions.
|
Re: Copilot review of 2026-08-06 14:49Z — Another suppressed comment in the review body, so again no thread to answer on. Applied in ba588aa. The finding is correct and it is the one site the previous pass missed: let safe = SafeAuthUser::from(user);
let tenant = log_safe(&tenant_id);
tracing::info!(user_id = %safe.id, tenant_id = %tenant, "register: user registered");It survived because it happened to fit on one line, which is exactly the fragile property — the nine sibling sites broke in this branch when Deliberately not touched: Verified: On the review's headline ( |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 26 out of 27 changed files in this pull request and generated no new comments.
Suppressed comments (3)
crates/bymax-auth-redis/tests/redis_stores.rs:355
- This test is time-based and can be flaky because Redis TTL is second-granularity and expiry timing depends on scheduling; after 1500ms the key may still exist briefly (e.g., TTL returns 0) even though it’s about to expire. Prefer polling with a bounded timeout until the key is absent (TTL == -2) or using a longer wait with a retry loop, so CI variance doesn’t cause intermittent failures.
tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
assert_eq!(
redis.ttl("auth:rp:p1").await,
-2,
"the short pointer must have expired"
);
docs/technical_specification.md:5129
- This sentence is potentially misleading now that the PR states both implementations write PHC and PHC is the only accepted encoding. Consider rephrasing to clarify the parity is about the default KDF selection (e.g., 'nest-auth defaults to scrypt') rather than implying nest-auth writes a non-PHC 'scrypt' format.
**Recommended vs default.** **Argon2id is the *recommended* writer for new deployments** — it is OWASP's first-choice memory-hard KDF and the more conservative choice against GPU/ASIC attackers — while **scrypt is the *default*** purely for drop-in parity with nest-auth, which writes scrypt. A greenfield deployment SHOULD enable the `argon2` feature (§19.2) and configure Argon2id as the writer (§19.3); scrypt verification is retained so any legacy hashes still validate and lazily migrate via rehash-on-verify. At the type level this is enforced by making `Argon2id` a `#[cfg(feature = "argon2")]` enum variant (§5.1.9): the default active algorithm is `Scrypt`, and Argon2id becomes *selectable* only once the feature is compiled in, so a default build can never name an uncompiled hasher.
crates/bymax-auth-crypto/src/password/tests.rs:395
- Using
unwrap_or_default()/unwrap_or(Null)here discards the underlying I/O/parse error details, which can make contract-path failures harder to diagnose (e.g., wrong working directory in CI). Consider failing fast with anexpect/error message that includes the path and the original read/parse error, so the failure mode is immediately actionable.
let raw = std::fs::read_to_string(path).unwrap_or_default();
let root: serde_json::Value = serde_json::from_str(&raw).unwrap_or(serde_json::Value::Null);
root.get("passwordHashFormat")
.and_then(|s| s.get("vectors"))
.and_then(serde_json::Value::as_array)
.cloned()
.unwrap_or_default()
Second cross-implementation security audit of this library and its NestJS sibling: six fresh-context agents plus a lane covering ASVS V14/V16 and cross-library parity, aimed at what the first audit's lenses did not reach.
Held in step with
bymaxone/nest-auth(PR #71). The wire contract stays byte-identical between the two repositories.What was wrong
oauth_initiatewas the one flow still reading the caller's tenant verbatim. Login, register, all four password-reset steps and both email-verification steps open withresolve_tenant. This one took?tenantId=straight from the query string and wrote it into the single-useos:state, which the callback then reads back forfind_by_oauth_id,CreateWithOAuthDataand theHookContext.It is also the worst flow to leave open, because it is the one that decides which tenant an account is provisioned into. Against a deployment configuring a
TenantIdResolver— host or subdomain tenancy, the reason the resolver exists — an unauthenticated caller could name any tenant, complete a normal sign-in with their own provider account, and be created or linked inside it.The doc comment rationalised it: "it is not validated here (the
on_oauth_loginhook enforces tenant membership)". That did not hold — the hook is handed the same value through itsHookContext, so a hook deciding on the profile alone admitted it. nest-auth closed this on its side and its comment names the stakes exactly; this side never got the fix.Four routes carried the wrong rate limit or none.
POST /auth/platform/logoutandDELETE /auth/platform/sessionswere mounted unthrottled, and bothrecovery-codesroutes were served undermfa_setup(5/60) instead ofmfa_disable(3/300) — 25x more permissive than the sibling on a route that is TOTP-gated and can repeatedly invalidate a victim's recovery codes./auth/platform/logoutis the one that mattered. It is public by deliberate design — a caller whose access token expired must still be able to kill their refresh session — which is exactly why it needs a limit. Unauthenticated and unthrottled, a loop of invented 64-hex strings drivesfind_session, an HMAC verify,revoke_sessionanddelete_grace_pointer: two to four round trips each holding one of the pool's connections.Nothing could catch this class. All 27 limit values are pinned against the shared contract and the contract is checked for extra names, but no test asserted which limit each route wears — wiring
logintoregister's 10/3600 would have passed the entire suite.The default breach screen refused every non-Latin password.
reduce_to_base_wordfiltered tois_ascii_alphanumeric, so a password in Cyrillic, Han, Kana, Hangul, Greek, Arabic, Hebrew or Thai reduced to the empty string — belowMIN_BASE_LENGTH, whichis_breachedanswerstruefor. Those users were refused on register, on reset and on change, and told their strong password was commonly used, pushing them onto the strictly smaller ASCII keyspace.tenant_idaccepted control characters. It arrives in the body of five public routes and is the caller's own value whenever no resolver is configured — the default — then reaches atracingevent, a Redis key segment and an HMAC preimage. nest-auth has always rejected these, so this was also a wire divergence: the same request was a 400 on one backend and a 200 on the other, which is precisely whatrequestFieldBoundsexists to prevent. A newline forges a record on a plain-text subscriber (ASVS 16.4.1).The strongest compromise signal was anonymous. Refresh-token reuse — a token already exchanged, presented again — was logged as bare prose, with the account reaching only a consumer who had wired
on_refresh_token_reuse_detected. The shipped hooks are no-ops.login: invalid credentialslogged neither the address nor the tenant, with both in scope as parameters of the enclosing function.The session index grew one permanent member per refresh. A rotation adds
rp:{old}alongsidert:{new}, only a full revoke-all ever removed it, andrefresh_rotatere-arms the set's TTL each time. Every reader is linear in its size, includinginvalidate_user_sessions, which walks it inside a script and so blocks the whole single-threaded store.What changed
Each fix carries a test that fails when the fix is reverted.
oauth_initiateresolves the tenant before anything is minted, and the resolved value is what reaches the state — so the callback cannot be talked into a different one either.no_control_characterson all ninetenant_idfields, andlog_safeat every one of the ten tracing sites that interpolate one. The DTO is the boundary;log_safeis the second lock for a value that reached a log line without passing one, which is what a host-supplied resolver returns.list_sessions_innerdrops the grace members whose pointer has expired, in the walk it was already doing. Pointers still inside their window are left alone — those are what let a revoke-all also kill a token rotated away moments earlier.The password-hash format is PHC on both sides, and only PHC. The two libraries could not read each other's hashes at all: this one writes PHC, nest-auth wrote
scrypt:N:r:p:{saltHex}:{derivedHex}. Because verification is total, the failure surfaced asinvalid_credentialsrather than a parse error, and thelf:counter is keyed identically on both — so five correct attempts by the owner locked the account out of the pair. nest-auth now writes PHC, andpasswordHashFormatpins the encoding with a known-answer vector from each implementation, replacing prose both sides could satisfy alone.There is deliberately no compatibility reader. Both libraries are new and have never backed a deployment, so one would be an unused branch in the credential-verification core — the same reasoning 6da0382 gave when it removed the earlier compatibility paths. This branch briefly added one and then removed it again; what it also cleans up is what that 2026-07-29 commit left behind: a specification paragraph describing the deleted shim, and two
exclude_repatterns naming functions that no longer exist. A stale exclusion is worse than none — it silently suppresses a future function that happens to share the name.Review findings, closed
A Rust code review and a security review over the full diff found five real defects — three of them in this branch's own work:
log_safewas applied at one tracing site out of ten, while its own doc comment describes it as the second lock for exactly the other nine.|| c == '\u{7f}'clause was dead and its comment stated the opposite:is_controldoes cover DEL (Unicode category Cc), confirmed by running it.#[allow(clippy::type_complexity)]this branch introduced — itself a finding under the workspace rules, and avoidable with a type alias that removes the complexity rather than hiding it. The workspace is back to zero#[allow]/#[expect].no_control_charactersacross nine DTOs, and the grace-pointer pruning. Both have one now; the pruning test runs against real Redis and lets the short pointer expire rather than deleting it, asserting both halves — the dead member is dropped and the live one survives.OAuthInitiateQuery::tenant_iddoc still cited the rationale the OAuth fix records as false.Verification
cargo fmt --check,clippy -D warnings --workspace --all-targets --all-features, workspace lib tests, the adapter integration tier and the real-Redisredis_storestier all clean. Mutation over the password module: 20 caught, 0 missed, 1 unviable.