Skip to content

feat(identity): let a legacy verifier report a password it did not check - #200

Merged
KyleJune merged 4 commits into
mainfrom
feat/legacy-verifier-not-checked
Nov 12, 2026
Merged

KyleJune merged 4 commits into
mainfrom
feat/legacy-verifier-not-checked

Conversation

@KyleJune

@KyleJune KyleJune commented Nov 12, 2026 •

Copy link
Copy Markdown
Member

Summary

A LegacyPasswordVerifier can now report that it did not check a password, separately from finding it wrong. It does this by throwing the new PasswordNotCheckedError (from @udibo/oauth2/identity/migration). IdentityService.signIn refuses that attempt the same way it refuses a wrong password, does not count it toward the account lockout, and emits sign_in.failed with the new reason not_checked.

Before this change, a verifier that declined to check, for example because its call budget was spent or a circuit breaker was open, looked like a wrong password. Each declined check stayed counted, so an attacker who drained the budget could lock accounts they never guessed against.

Why a typed error and not a third resolved value

  • The verify signature stays Promise<boolean>, and true/false mean what they meant. Existing verifiers compile and behave as before.
  • It fails closed for every caller that already exists. A third resolved value such as "not_checked" is truthy. Any application code that calls verifier.verify() itself and tests the result with if (await verify(...)) would treat it as a match and sign the person in, and the type checker would not catch that. A throw reads as a failure to every existing caller: the service before this change, verifyLegacyPassword, and an application's own code.
  • One signal covers both seams. resolveUnknownIdentifier can throw the same error, because its return type has no room for another value.

Changes

  • src/identity/migration.ts: adds PasswordNotCheckedError. The verify JSDoc now says when to throw it. Throw it only when the password never left the process. A request that was sent and then timed out, or came back with something other than a verdict, still resolves false and still counts. verifyLegacyPassword rethrows this error instead of resolving false, so a dry run never reports an unchecked password as wrong. Other throws still resolve false.
  • src/identity/service.ts:
    • signIn refuses a not-checked outcome with the same null, the same equalizing hash and the same failedSignInFloorMs as a wrong imported password. It does not log the outcome as "verifier threw".
    • With reserveAttempt, the service takes the reservation back through the new optional AccountLockoutLike.releaseAttempt. When that method is absent or returns false, the attempt stays counted, and if it was the justLocked reservation the lockout event still fires, so an unlock email is not lost. Without reserveAttempt, recordFailure is skipped.
    • When resolveUnknownIdentifier throws the error, the refusal is unchanged, nothing is logged, and the event reason is not_checked.
    • Every other throw keeps its current behaviour.
  • src/identity/events.ts: adds "not_checked" to SignInFailureReason.
  • src/identity/lockout.ts: these are optional, additive members (docs/stability.md).
    • AccountLockoutLike.releaseAttempt?(userId, now?) => Promise<boolean>.
    • LockoutStore.release?(userId, maxAttempts, now). It removes one count atomically, never goes below zero, and drops lockedUntil once the count falls below maxAttempts. That handles overlapping not-checked attempts: the last reservation reaches the threshold and locks before any is released, and the releases then undo that lock. A lock the remaining counted attempts still reach stays in place.
    • AccountLockout.releaseAttempt and MemoryLockoutStore.release implement these members. AccountLockout.releaseAttempt resolves false when the store has no release.
  • src/testing/contract/lockout-store.ts: adds optional release cases, including an atomicity case where releases and increments run concurrently, plus a release: true option that makes them required. The memory store runs them as required.
  • Docs:
    • The migration guide has a new section, "When a verifier cannot check the password", and a note on the resolver.
    • add-login.md covers the store's release method.
    • trigger-points.md has updated rows.
    • known-limitations.md documents the remaining window, described below.
    • llms-full.txt has been regenerated.
    • CHANGELOG.md is generated by semantic-release from this commit, so it is not edited by hand.

Lockout accounting after review

Review found two problems. First, a sign-in could read the account as unlocked and then reserve its attempt after overlapping unchecked attempts had locked it. That sign-in was refused but stayed counted, so the unchecked attempts alone could leave the account locked, with no lockout event. Second, a lock could survive the release of the justLocked reservation without being announced, which in log-only mode is the only signal there is. Changes made in response:

  • Refused reservations are released. Under "enforce", an attempt refused because the account is locked is now taken back through releaseAttempt when the lockout implements it. A refused attempt never checks a password, so not counting it never admits an extra check. During a lock earned by real failures, the count stays at or above maxAttempts and the lock stands. This is a change in counting: with AccountLockout over a store that has release, failures no longer grows while an account is locked.
  • A surviving lock is announced. After a successful release of a justLocked reservation, the service reads status and fires the lockout event if the account is still locked.
  • A failing release still announces. If releaseAttempt throws, the service fires the event for a justLocked reservation before rethrowing, as abandon does.
  • release leaves a record whose lock has expired alone. The next attempt then starts a fresh count, as it would after any expired lock. The contract and the suggested SQL now say so.
  • The contract's concurrency case now catches a read-modify-write release. One case runs releases only, and another mixes releases and increments with counts that a lost update cannot reach.
  • Docs:
    • The guidance now says "remote checking switched off (monitor-only), so no request is made", and says never to send the password and then throw.
    • It notes that the error is recognised by instanceof, so it must come from the same copy of the package.
    • The LockoutRecord.failures and reserveAttempt JSDoc are updated.

Remaining approximations, documented in known-limitations.md, all low severity:

  • A release takes back a count, not a specific attempt. A reset during an in-flight unchecked attempt can therefore let that release erase one later wrong-password count.
  • A wrong password can reach the threshold, announce a lock, and then have that lock dropped when an in-flight unchecked attempt is released. The next wrong password locks and announces again.
  • A release that lands between an attempt reaching maxAttempts and its conditional lock write makes AccountLockout count that attempt twice. This leaves one extra count until a successful sign-in, and it errs strict.
  • A wrong password that re-locks the account in the gap between a released justLocked attempt dropping its lock and the service re-reading the lock state is announced twice.

A second round of review at ba236733 found nothing blocking. The follow-up commit adds a test pinning that a not-checked attempt released over a lock already announced does not announce again; the guard mutation now fails that test. It also documents the two residuals above.

Testing

pnpm check and the full pnpm test run pass (153 files; 4843 passed, 44 skipped).

New tests:

  • src/identity/service.test.ts:
    • Running more than maxAttempts consecutive not-checked attempts leaves the count at 0 and the account unlocked, and the next correct password signs in.
    • With maxAttempts overlapping not-checked attempts, the account is transiently locked and then unlocked, with no lockout event, and the next correct password signs in.
    • A flood of extra attempts during that transient lock is refused without being counted.
    • Wrong passwords around a not-checked attempt keep counting and lock at the threshold.
    • sign_in.failed reports reason not_checked, and wrong_password is unchanged.
    • A not-checked attempt is not logged as a throw.
    • Without reserveAttempt, recordFailure is never called.
    • When the lockout cannot release, the attempt stays counted and the lock is announced once.
    • An ordinary throw still counts as wrong_password.
    • The caller sees the same result for a not-checked attempt as for a wrong password, both held to the floor. The not-checked branch was added to the "every rejected branch lands on the floor" table. With no floor it still spends the equalizing hash.
    • The resolver reports not_checked.
  • src/identity/lockout.test.ts: releaseAttempt takes back one count, drops the lock it created, keeps a lock that still stands, leaves a missing record alone, and resolves false without store support.
  • src/identity/migration.test.ts: verifyLegacyPassword rethrows the error, and other throws still resolve false.
  • The LockoutStore contract has new release cases.

Each behaviour was checked against a seeded mutation of the source, and the intended assertions failed:

Mutation Tests failed
Not-checked recorded as a failure 4
Error not recognised 6
Reservation never released 3
Memory release keeps the lock 3
Unreleased justLocked not announced 1
No equalizing hash 1
Resolver reason reported as unknown_identifier 1
No-reserve path records a failure 1
releaseAttempt claims success without store support 1
verifyLegacyPassword swallows the error 1

Each review fix was mutation-tested the same way:

Mutation Tests failed
Refused reservation kept 1
Surviving lock not announced 1
Failed release not announced 1
Release on an expired lock 1
Read-modify-write release 8

One mutant survived: keeping a zero-count record instead of deleting it. The contract allows either, so that mutant is equivalent.

Related Issues

Closes #195

🤖 Generated with Claude Code

A LegacyPasswordVerifier (or resolveUnknownIdentifier) can throw
PasswordNotCheckedError when it never checked the password, such as a
spent call budget or an open circuit breaker. signIn refuses the attempt
exactly as it refuses a wrong password, does not count it toward the
account lockout, and emits sign_in.failed with reason not_checked.

Adds the optional AccountLockoutLike.releaseAttempt and
LockoutStore.release, implemented by AccountLockout and
MemoryLockoutStore, with matching LockoutStore contract cases.

Closes #195

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@codecov

codecov Bot commented Nov 12, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 94.19%. Comparing base (4d208f4) to head (7075593).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #200      +/-   ##
==========================================
+ Coverage   94.07%   94.19%   +0.12%     
==========================================
  Files         148      148              
  Lines       11729    11834     +105     
  Branches     2765     2796      +31     
==========================================
+ Hits        11034    11147     +113     
+ Misses        232      229       -3     
+ Partials      463      458       -5     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

…lock

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
KyleJune and others added 2 commits November 12, 2026 06:26
…residuals

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d the error default

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@KyleJune
KyleJune merged commit 724cc26 into main Nov 12, 2026
13 checks passed
KyleJune pushed a commit that referenced this pull request Nov 12, 2026
## [0.54.0](0.53.0...0.54.0) (2026-11-12)

### Features

* **identity:** let a legacy verifier report a password it did not check ([#200](#200)) ([724cc26](724cc26)), closes [#195](#195)
@github-actions

Copy link
Copy Markdown

🎉 This PR is included in version 0.54.0 🎉

The release is available on:

Your semantic-release bot 📦🚀

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Legacy verifiers cannot report "not checked", so an unavailable check counts toward lockout

1 participant