Skip to content

fix(login): stop reading a 401 from /login as "no such account" - #170

Merged
Bccorb merged 1 commit into
mainfrom
fix/login-no-longer-enumerable
Sep 4, 2026
Merged

fix(login): stop reading a 401 from /login as "no such account"#170
Bccorb merged 1 commit into
mainfrom
fix/login-no-longer-enumerable

Conversation

@Bccorb

@Bccorb Bccorb commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Follow-up to fells-code/seamless-auth-api#257, which made POST /login non-enumerable.
That change closed the oracle on the API side and broke the one branch in this repo that
depended on it.

What broke

POST /login no longer answers 401. An identifier with no usable account (unknown,
unverified, or with no permitted continuation method) now gets 200 and a decoy pre-auth
token, so the response cannot be used to test whether an account exists.

Two branches in completeLogin read that 401:

  • loginFlow.ts:112 mapped 401 + a message matching /verify/ to "The account for X is
    not verified yet."
  • loginFlow.ts:122 mapped 401/403 to "No account was found for X."

Both are now unreachable for 401, and both are removed rather than rewired. There is no
longer an answer for them to read, which is the point of the upstream change.

The part that is not just deleting dead code

The surrounding messages were asserting something this CLI can no longer know:

Before After
A code was sent to dev@example.com. If an account exists for dev@example.com, a code is on its way.
This account cannot use email otp login. Available methods: ... email otp login is not available for dev@example.com. Offered: ...
Could not verify a code. Run seamless login to try again. Could not verify a code for X. If that address or number has no account yet, register it first. Otherwise run seamless login to try again.

The middle one matters more than it looks: loginMethods comes back for an unknown
identifier too, so "this account cannot" was reporting an account that may not exist.

An unknown identifier now runs the ordinary flow and fails at the code step, after three
prompts. That is the intended behaviour and not something the CLI can shortcut, so the
final error says what the likely reason is instead of leaving the developer to guess.

403 keeps a branch but no longer claims the account is missing, and 423 is now reported
on its own terms with how long to wait, rather than falling through to
Login request failed (423). It is also the one answer left that does imply an account
exists, which is a deliberate and documented tradeoff upstream.

Conformance spec

Adds verify/harness/api/loginEnumeration.spec.ts to the api layer. The API has unit
coverage for all of this; what only this harness can check is that the guarantee survives a
real instance with real signing keys and the real login policy.

Seven tests: same shape for a known and an unknown identifier, a stable subject and method
list across repeat probes, an OTP send that reports success and sends nothing, a verify that
fails the way a wrong code fails, a credential id that cannot exist refused identically for
both, and a malformed identifier still rejected with 400.

The loginMethods assertion is worth reading before approving. My first draft asserted
that a decoy and a real account return the same list, and it failed against a live instance:

- Expected   Array [
+ Received   Array [
+   "passkey",
        "magic_link",
        "email_otp",
+   "phone_otp",
    ]

That is correct upstream behaviour, not a bug. The list is filtered by what an account can
actually do, and a decoy's capabilities are derived per identifier, so any one decoy and any
one account can legitimately differ. The guarantee is the weaker and more useful one: a real
account's list must be one a decoy can also produce, which is what stops a narrow list
being proof of existence. The spec asserts that over a sample of 40 identifiers (each combo
comes up about a quarter of the time, so missing it is roughly a 1-in-100,000 event, not a
flake).

I would not have found this without running the harness, which is the argument for the spec
existing.

Verification

  • npm run build clean, npm test 818 passed / 4 skipped, verify/harness tsc --noEmit
    clean.
  • seamless verify --api-only --filter=loginEnumeration run locally against the merged API
    on main: 7 passed, exit 0.

Note for whoever runs this locally: seamless verify binds port 5312 and the compose file
hardcodes it, so anything else on that port has to be stopped first. Unrelated to this
change, but it cost me a run to work out.

POST /login no longer answers 401. An identifier with no usable account, which
used to cover unknown, unverified, and no permitted method, now gets 200 and a
decoy pre-auth token so the response cannot be used to test whether an account
exists.

Two branches in completeLogin read that 401 and are unreachable now: the "not
verified yet" message and "No account was found for X". Both go, because there is
no longer an answer for them to read.

The messages around them were claiming something the CLI can no longer support.
"A code was sent to X" becomes "If an account exists for X, a code is on its
way", and "This account cannot use email otp login" is phrased as what the
instance offered, since that list comes back for an unknown identifier too.

An unknown identifier therefore runs the ordinary flow and fails at the code
step. That is intended and not something the CLI can shortcut, so the final error
names the identifier and points at registering rather than saying only that a
code could not be verified.

423 is reported on its own terms with how long to wait, instead of falling
through to a bare status. It is also the one answer left that does imply an
account exists.

Adds a conformance spec pinning the guarantee against a running instance. The
loginMethods assertion is deliberately not equality between one account and one
decoy: that list is filtered by what an account can do and a decoy's
capabilities are derived per identifier, so any two can differ. What must hold is
that a real account's list is one a decoy can also produce, and the first draft
of the spec asserted equality and failed against a live instance for that reason.
@Bccorb
Bccorb merged commit cdc80eb into main Sep 4, 2026
2 checks passed
@Bccorb
Bccorb deleted the fix/login-no-longer-enumerable branch September 4, 2026 02:51
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