Confirm a typed password without replaying it over JMAP
ci / version (pull_request) Skipped
ci / node (pull_request) Successful in 1m5s
ci / publish (pull_request) Skipped
ci / announce (pull_request) Skipped
ci / docker-build (pull_request) Successful in 35s

Creating an app password asks for the account password. A session
holding a token has no password to compare with, so it sent the typed
one to the mail server as HTTP Basic on the JMAP session. INBUXA's
server now takes no password outside DAV (contract C-23), so that check
would always fail.

It now asks the server's sign-in endpoint, the one its own sign-in page
posts to, as this client, to its registered redirect URI, with a PKCE
challenge whose verifier is thrown away so the code can never be
exchanged. "Two-factor code needed" counts as confirmed: the server
says so only after the password matched, so accounts with two-factor
sign-in now pass where the Basic check failed them.

The mock answers /api/auth like the server and can refuse Basic on
JMAP; the app-password test turns that on, and fails on the old check.
This commit is contained in:
jcoffey-dev committed 2026-09-29 06:50:32 -07:00
1 parent d73f5e8f9e
commit 1acf2f29e7
5 files changed
+86 -8

No files matched your search

+4 -5
View File
@@ -41,7 +41,7 @@ import {
revokeAppPassword,
} from "./account.js";
import { imageProxyHandler } from "./imageproxy.js";
import { SignInError, finish as finishSignIn, needsRefresh, oauthEnabled, refreshTokens, singleServer, start as startSignIn, type TokenSet } from "./oauth.js";
import { SignInError, finish as finishSignIn, needsRefresh, oauthEnabled, passwordConfirms, refreshTokens, singleServer, start as startSignIn, type TokenSet } from "./oauth.js";
import { icsProxyHandler } from "./icsproxy.js";
import { staticHandler } from "./static.js";
import { mailNode, webmailNode } from "./nodes.js";
@@ -1127,11 +1127,10 @@ async function readJson<T>(c: Context): Promise<T | null> {
*/
async function confirmsPassword(session: LiveSession, candidate: string): Promise<boolean> {
if (session.tokens) {
// Holding no password, the only judge is the server.
// Holding no password, the only judge is the server, asked on its sign-in
// endpoint since it takes no password over JMAP.
try {
const authorization = `Basic ${Buffer.from(`${session.username}:${candidate}`, "utf8").toString("base64")}`;
await fetchUpstreamSession(authorization, upstreamFor(session.username));
return true;
return await passwordConfirms({ base: upstreamFor(session.username), username: session.username, password: candidate });
} catch {
return false;
}