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:
@@ -206,6 +206,32 @@ export function createApp(): Hono<Env> {
|
||||
const info = await getAccountInfo(session.id, session.authorization, upstream);
|
||||
return c.json(localizeSession(upstream, sessionExtras(session, info)));
|
||||
} catch (err) {
|
||||
// A rejected sign-in that carried a two-factor code is worth explaining
|
||||
// rather than calling "invalid credentials", because the credentials are
|
||||
// very likely fine.
|
||||
//
|
||||
// Stalwart accepts a TOTP code only through an OAuth flow -- its own web
|
||||
// interface is an OAuth client, which is why signing in there works. It
|
||||
// offers no password grant, so a client holding a username and password
|
||||
// cannot exchange them plus a code for a token, and the concatenated
|
||||
// `password$code` form ihasmail sent is not a route the server has. Its
|
||||
// documented answer for clients like this one is an app password, which
|
||||
// bypasses TOTP entirely.
|
||||
//
|
||||
// ihasmail already relies on that elsewhere: turning 2FA *on* mints an
|
||||
// app password and moves the session onto it, precisely because a plain
|
||||
// password stops working from that moment. The sign-in page was the one
|
||||
// place still pretending otherwise.
|
||||
if (totp && err instanceof UpstreamError && err.status === 401) {
|
||||
return c.json(
|
||||
{
|
||||
error: "totp_unsupported",
|
||||
message:
|
||||
"This mail server does not accept two-factor codes from webmail. Sign in with an app password instead — create one in Stalwart's own settings, under app passwords. Your password and code are probably fine.",
|
||||
},
|
||||
401,
|
||||
);
|
||||
}
|
||||
return upstreamFailure(c, err);
|
||||
}
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user