Skip to content

feat: add attemptWithReason() for login failure reasons - #37

Merged
ramonmalcolm10 merged 2 commits into
mainfrom
feat/attempt-with-reason
Aug 23, 2026
Merged

ramonmalcolm10 merged 2 commits into
mainfrom
feat/attempt-with-reason

Conversation

@ramonmalcolm10

Copy link
Copy Markdown
Owner

Why

attempt() resolves the user and verifies the hash, then collapses both into a boolean. Callers who want to tell someone "no account found with that email" were paying a second lookup to recover what the attempt had already established one line earlier.

That workaround is also wrong in a way that's hard to see: a Google or passkey signup has a row, so findFirst({ where: { email } }) reports the account as existing, and the user is told "invalid email or password" for an account where no password can ever succeed. Only the attempt itself can tell the difference — attemptWithReason() reports it as no_password.

API

const result = await auth().attemptWithReason({ email, password });

if (!result.ok) {
  result.reason; // 'no_user' | 'no_password' | 'bad_password' | 'rejected'
}

Two invariants the design rests on

  1. attempt() keeps returning Promise<boolean>. Widening it would turn every if (!await attempt()) downstream into a permanently false branch — objects are always truthy — and log everyone in with no type error at the call site.
  2. hash.verify() still runs unconditionally before any branch that can return early, against the cached dummy hash on a user miss. Asking for the reason must not buy back the timing oracle that exists to close. There's a test asserting the miss path still pays for a verify.

Disclosure stays with the caller

The reason is a fact, not a message. What reaches a screen is an application decision, so the policy ships as a guide rather than as configuration — including the probe-budget pattern, the "never surface bad_password" rule, and the point that withholding at login buys little if registration already answers the same question.

Contents

  • types.tsAttemptFailure, AttemptResult, AuthInstance.attemptWithReason
  • auth-instance.tsattempt() body hoisted into a shared runAttempt()
  • index.ts — exports both types
  • __tests__/auth.test.ts — 7 tests: one per reason, success shape, dummy-hash-still-runs, agreement with attempt()
  • docs — API reference section + new Login Error Messages guide

Second commit is unrelated housekeeping kept separate: the reference declared the factory as () => AuthInstance<TUser>, stale since per-request autoTouch landed in 36970fe. A bug report cited it as evidence auth({ autoTouch: true }) was unsupported.

Verification

bun test233 pass, 0 fail. Docs build clean.

attempt() resolves the user and verifies the hash, then collapses both
into a boolean. Callers who want to tell someone "no account found with
that email" were paying a second lookup to recover what the attempt had
already established one line earlier.

That workaround is also wrong in a way that is hard to see: a Google or
passkey signup has a row, so a findFirst by email reports the account as
existing, and the user is told "invalid email or password" for an account
where no password can ever succeed. Only the attempt itself can tell the
difference. attemptWithReason() reports it as no_password.

Two invariants hold the design:

- attempt() keeps returning Promise<boolean>. Widening it would turn
  every `if (!await attempt())` downstream into a permanently false
  branch, since objects are always truthy, and log everyone in without a
  type error at the call site.
- hash.verify() still runs unconditionally before any branch that can
  return early, against the cached dummy hash on a user miss. Asking for
  the reason must not buy back the timing oracle that exists to close.

The reason is a fact, not a message. Deciding what reaches a screen stays
with the application, so the disclosure policy ships as a guide rather
than as configuration.
The reference declared the factory as `() => AuthInstance<TUser>`, which
has been wrong since per-request autoTouch landed in 36970fe. A bug
report against this package cited the stale signature as evidence that
`auth({ autoTouch: true })` was unsupported, and it is supported.
@ramonmalcolm10
ramonmalcolm10 merged commit 4c2fb57 into main Aug 23, 2026
1 check passed
@ramonmalcolm10
ramonmalcolm10 deleted the feat/attempt-with-reason branch August 23, 2026 13:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant