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:
@@ -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");
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user