From 1acf2f29e70203f0f61591ac906cebda8503d11a Mon Sep 17 00:00:00 2001 From: John Coffey Date: Tue, 29 Sep 2026 06:50:32 -0700 Subject: [PATCH] Confirm a typed password without replaying it over JMAP 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. --- server/src/app.ts | 9 ++++---- server/src/mock/index.ts | 4 ++-- server/src/mock/oauth.ts | 46 +++++++++++++++++++++++++++++++++++++++- server/src/oauth.test.ts | 2 ++ server/src/oauth.ts | 33 ++++++++++++++++++++++++++++ 5 files changed, 86 insertions(+), 8 deletions(-) diff --git a/server/src/app.ts b/server/src/app.ts index ceb85c2..c350bab 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -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(c: Context): Promise { */ async function confirmsPassword(session: LiveSession, candidate: string): Promise { 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; } diff --git a/server/src/mock/index.ts b/server/src/mock/index.ts index 729ebb1..491e2de 100644 --- a/server/src/mock/index.ts +++ b/server/src/mock/index.ts @@ -14,7 +14,7 @@ import { MAX_OBJECTS, MethodError, directory, enforceLimits, resolveRefs } from import { handlers } from "./handlers.js"; export { account } from "./config.js"; import { checkOtp } from "./auth.js"; -import { checkBearer, handleOAuth } from "./oauth.js"; +import { basicRefused, checkBearer, handleOAuth } from "./oauth.js"; import { sseClients, broadcast } from "./events.js"; /* ---------- http ---------- */ @@ -26,7 +26,7 @@ function unauthorized(res: ServerResponse) { function checkAuth(req: IncomingMessage): boolean { const h = req.headers.authorization ?? ""; if (checkBearer(h)) return true; - if (!h.startsWith("Basic ")) return false; + if (!h.startsWith("Basic ") || basicRefused) return false; const raw = Buffer.from(h.slice(6), "base64").toString(); const sep = raw.indexOf(":"); if (sep < 0) return false; diff --git a/server/src/mock/oauth.ts b/server/src/mock/oauth.ts index 2f66115..c1b9957 100644 --- a/server/src/mock/oauth.ts +++ b/server/src/mock/oauth.ts @@ -23,11 +23,18 @@ const refreshTokens = new Map(); const base = () => `http://127.0.0.1:${PORT}`; +/** + * Whether JMAP refuses Basic, as INBUXA's server does outside DAV (contract + * C-23). Off by default, since the mock also serves password sign-in. + */ +export let basicRefused = false; + /** For tests: how long new access tokens last, and a way to end every token. */ export const oauthMock = { setAccessTokenTtl(seconds: number) { accessTokenTtl = seconds; }, expireAccessTokens() { for (const t of accessTokens.values()) t.expiresAt = 0; }, - reset() { codes.clear(); accessTokens.clear(); refreshTokens.clear(); accessTokenTtl = 3600; }, + refuseBasic(on: boolean) { basicRefused = on; }, + reset() { codes.clear(); accessTokens.clear(); refreshTokens.clear(); accessTokenTtl = 3600; basicRefused = false; }, }; /** A bearer token the mock issued, still valid under the current password. */ @@ -50,6 +57,14 @@ function readForm(req: IncomingMessage): Promise { }); } +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve) => { + const chunks: Buffer[] = []; + req.on("data", (c) => chunks.push(c)); + req.on("end", () => resolve(Buffer.concat(chunks))); + }); +} + function issue(res: ServerResponse, refresh: string | null) { const access = `mock-at-${randomBytes(16).toString("hex")}`; accessTokens.set(access, { password: account.password, expiresAt: Date.now() + accessTokenTtl * 1000 }); @@ -93,6 +108,35 @@ export async function handleOAuth(req: IncomingMessage, res: ServerResponse, url res.end(); return true; } + if (url.pathname === "/api/auth" && req.method === "POST") { + // The server's sign-in page posts here; the mock answers for the demo user. + let body: Record; + try { + body = JSON.parse((await readBody(req)).toString()) as Record; + } catch { + json(res, 400, { error: "invalid_request" }); + return true; + } + const redirectUri = typeof body.redirectUri === "string" ? body.redirectUri : ""; + if (body.type !== "authCode" || body.clientId !== OAUTH_CLIENT_ID || !redirectUri || body.codeChallengeMethod !== "S256") { + json(res, 400, { error: "invalid_request" }); + return true; + } + const secret = typeof body.accountSecret === "string" ? body.accountSecret : ""; + const passwordOk = body.accountName === USER && (secret === account.password || account.appPasswords.some((a) => a.secret === secret)); + if (!passwordOk) { + json(res, 200, { type: "failure" }); + return true; + } + if (account.otpUrl && secret === account.password && !body.mfaToken) { + json(res, 200, { type: "mfaRequired" }); + return true; + } + const code = randomBytes(16).toString("hex"); + codes.set(code, { challenge: String(body.codeChallenge ?? ""), redirectUri, issuedAt: Date.now() }); + json(res, 200, { type: "authenticated", client_code: code, iss: base() }); + return true; + } if (url.pathname === "/auth/token" && req.method === "POST") { const form = await readForm(req); if (form.get("client_id") !== OAUTH_CLIENT_ID || form.get("client_secret") !== OAUTH_CLIENT_SECRET) { diff --git a/server/src/oauth.test.ts b/server/src/oauth.test.ts index d18e647..236111c 100644 --- a/server/src/oauth.test.ts +++ b/server/src/oauth.test.ts @@ -198,6 +198,8 @@ test("a password change signs the session out, since the server revokes its toke test("creating an app password checks the typed password with the server", async () => { await signIn(); + // As INBUXA's server does: no password over JMAP (contract C-23). + oauthMock.refuseBasic(true); const wrong = await call("/api/account/app-passwords", { method: "POST", body: JSON.stringify({ description: "Phone", current: "nope" }) }); assert.equal(wrong.status, 403); const right = await call("/api/account/app-passwords", { method: "POST", body: JSON.stringify({ description: "Phone", current: "demo-password" }) }); diff --git a/server/src/oauth.ts b/server/src/oauth.ts index 5490ed1..e9c81d0 100644 --- a/server/src/oauth.ts +++ b/server/src/oauth.ts @@ -134,6 +134,39 @@ export async function start(params: { username: string; base: string; remember: return { location: url.toString(), state }; } +/** + * Whether `password` is the account's password, for a session that holds a + * token and so has no password to compare with. + * + * Asked of the server's sign-in endpoint, the one its own sign-in page posts + * to, because the server takes no password over JMAP (contract C-23). The + * request is this client's, to its registered redirect URI, so it passes the + * same checks a real sign-in does. A code it issues can never be exchanged: + * the PKCE verifier behind its challenge is thrown away here. + * + * "Two-factor code needed" counts as confirmed: the server says so only once + * the password has matched. + */ +export async function passwordConfirms(params: { base: string; username: string; password: string }): Promise { + const res = await fetch(absoluteUpstream("/api/auth", params.base), { + method: "POST", + headers: { "content-type": "application/json", accept: "application/json" }, + body: JSON.stringify({ + type: "authCode", + accountName: params.username, + accountSecret: params.password, + clientId: config.oauthClientId, + redirectUri: redirectUri(), + codeChallenge: challengeOf(randomToken(48)), + codeChallengeMethod: "S256", + }), + signal: AbortSignal.timeout(config.upstreamTimeout), + }); + if (!res.ok) throw new UpstreamError(`Password check failed (${res.status})`, 502); + const answer = (await res.json()) as { type?: string }; + return answer.type === "authenticated" || answer.type === "mfaRequired"; +} + export class SignInError extends Error { constructor(readonly code: "state_mismatch" | "expired" | "denied" | "exchange_failed", message: string) { super(message);