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
+2 -1
View File
@@ -127,7 +127,7 @@ docker compose up --build -d
# → http://localhost:8080 (put Caddy/nginx in front for TLS; see Caddyfile.example / nginx.example.conf) # → http://localhost:8080 (put Caddy/nginx in front for TLS; see Caddyfile.example / nginx.example.conf)
``` ```
Users sign in with their Stalwart mailbox credentials (TOTP codes are supported via the "two-factor code" field, which Stalwart accepts as `password$code`). Users sign in with their Stalwart mailbox credentials. **An account with two-factor authentication needs an app password**, created in Stalwart's own settings: Stalwart accepts a TOTP code only through an OAuth flow — its web interface is an OAuth client, which is why signing in *there* works — and it offers no password grant, so no client holding a username and password can exchange them plus a code for a token. The concatenated `password$code` form this README used to claim was accepted is not a route the server has. App passwords bypass TOTP and are Stalwart's own answer for clients like this one; ihasmail already relies on that, since turning 2FA on moves the current session onto an app password for exactly this reason.
## Development ## Development
@@ -306,6 +306,7 @@ works the same way — and dropped where 0.15 was the whole subject. Support for
- Snooze (nothing in JMAP or Stalwart supports it, and ihasmail never stores a password, so nothing could act on a mailbox while you are away) - Snooze (nothing in JMAP or Stalwart supports it, and ihasmail never stores a password, so nothing could act on a mailbox while you are away)
- Translations (strings are English-only for now) - Translations (strings are English-only for now)
- **Two-factor sign-in.** Today an account with 2FA must use an app password (see Quick start). Supporting a TOTP code directly means implementing OAuth: Stalwart offers the authorization-code and device flows and no password grant, so ihasmail would hand sign-in to Stalwart's own login and come back with a token. That is a better security posture than the sealed password it holds now — a refresh token rather than a credential — but it replaces ihasmail's own sign-in page for those users and may need an OAuth client registered. Reported as [#75](https://github.com/LINUXexpert-org/ihasmail/issues/75)
## License ## License
+30
View File
@@ -174,3 +174,33 @@ test("credential endpoints reject unauthenticated callers", async () => {
assert.equal((await post("/api/account/2fa/begin", {})).status, 401); assert.equal((await post("/api/account/2fa/begin", {})).status, 401);
cookie = saved; 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");
});
+26
View File
@@ -206,6 +206,32 @@ export function createApp(): Hono<Env> {
const info = await getAccountInfo(session.id, session.authorization, upstream); const info = await getAccountInfo(session.id, session.authorization, upstream);
return c.json(localizeSession(upstream, sessionExtras(session, info))); return c.json(localizeSession(upstream, sessionExtras(session, info)));
} catch (err) { } 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); return upstreamFailure(c, err);
} }
}); });
+13 -1
View File
@@ -79,7 +79,19 @@ export function LoginPage() {
<div className="field"> <div className="field">
<label htmlFor="t">Two-factor code</label> <label htmlFor="t">Two-factor code</label>
<input id="t" className="input" inputMode="numeric" autoComplete="one-time-code" placeholder="123456" value={totp} onChange={(e) => setTotp(e.target.value)} autoFocus /> <input id="t" className="input" inputMode="numeric" autoComplete="one-time-code" placeholder="123456" value={totp} onChange={(e) => setTotp(e.target.value)} autoFocus />
<span className="hint">Enter the code from your authenticator app if your account uses 2FA.</span> {/*
Kept, and honest about itself. Stalwart accepts a TOTP code only
through an OAuth flow, and offers no password grant, so no client
holding a username and password can pass one — the field cannot
work here today. It stays because someone with 2FA will look for
it, and finding nothing is worse than finding this; the hint sends
them somewhere that does work, and the server explains it again if
they try anyway.
*/}
<span className="hint">
Most mail servers, Stalwart included, do not accept two-factor codes from webmail use an app password instead, created in
your mail server's own settings. This field is here for servers that do.
</span>
</div> </div>
) : ( ) : (
<button type="button" className="btn btn-ghost btn-sm" style={{ marginBottom: 12, color: "var(--fg-muted)" }} onClick={() => setShowTotp(true)}> <button type="button" className="btn btn-ghost btn-sm" style={{ marginBottom: 12, color: "var(--fg-muted)" }} onClick={() => setShowTotp(true)}>