Stop pretending the two-factor field can work

Signing in with a two-factor code failed with a bare 401 and "Invalid
credentials", which sent the user off to check a password that was
perfectly good (#75). It cannot work, and the app already knew.

Stalwart accepts a TOTP code only through an OAuth flow -- its own web
interface is an OAuth client, which is why signing in *there* succeeds --
and it offers only the authorization-code and device flows. There is no
password grant, so a client holding a username and password has nowhere
to exchange them plus a code for a token. The concatenated
`password$code` form this README claimed was accepted is not a route the
server has, and appears never to have been. What was verified live on
0.16.19 was enabling and disabling 2FA, never signing in with a code.

The contradiction was already in the codebase: turning 2FA *on* mints an
app password and reseals the session onto it, precisely because a plain
password stops working from that moment. The sign-in page was the one
place still assuming otherwise.

Three changes, no new capability:

  - A 401 on a sign-in that carried a code now says what is happening
    and where to go instead, and says the password is probably fine.
    A sign-in without a code is untouched, so an ordinary typo still
    reads as an ordinary typo.

  - The field stays, and is honest about itself. Removing it would leave
    someone with 2FA finding nothing at all, which is worse than finding
    a field that explains the situation and points at app passwords.

  - The README's claim is corrected rather than quietly dropped, and
    real 2FA support is written into the roadmap as what it is: an OAuth
    implementation, handing sign-in to Stalwart and holding a refresh
    token instead of a sealed password.
This commit is contained in:
2026-08-26 14:46:08 -07:00
parent b37c422e7d
commit d98c425a9a
4 changed files with 71 additions and 2 deletions
+30
View File
@@ -174,3 +174,33 @@ test("credential endpoints reject unauthenticated callers", async () => {
assert.equal((await post("/api/account/2fa/begin", {})).status, 401);
cookie = saved;
});
/**
* A sign-in carrying a two-factor code that the server rejects is almost never
* "wrong password". Stalwart accepts TOTP only through an OAuth flow and offers
* no password grant, so the concatenated form ihasmail sends cannot work — and
* saying "invalid credentials" sends the user to check a password that is fine.
*
* Reported as #75: 2FA sign-in failed with a bare 401 while an app password
* worked, which is Stalwart's documented route and gave no hint of itself.
*/
test("a rejected sign-in carrying a TOTP code explains itself", async () => {
const saved = cookie;
cookie = "";
const res = await post("/api/auth/login", { username: "[email protected]", password: "demo-password", totp: "123456" });
cookie = saved;
assert.equal(res.status, 401);
assert.equal(res.body.error, "totp_unsupported", "not the generic invalid_credentials");
assert.match(res.body.message, /app password/i, "points at the route that does work");
assert.match(res.body.message, /probably fine/i, "does not blame the password");
});
test("a rejected sign-in without a code is still a plain credential failure", async () => {
// The explanation must not leak onto ordinary typos.
const saved = cookie;
cookie = "";
const res = await post("/api/auth/login", { username: "[email protected]", password: "wrong" });
cookie = saved;
assert.equal(res.status, 401);
assert.equal(res.body.error, "invalid_credentials");
});