Merge pull request 'Confirm a typed password without replaying it over JMAP' (#30) from fix/confirm-password-without-basic into main
ci / version (push) Skipped
ci / node (push) Successful in 2m32s
ci / publish (push) Skipped
ci / announce (push) Skipped
ci / docker-build (push) Successful in 2m20s

This commit was merged in pull request #30.
This commit is contained in:
jcoffey-dev committed 2026-09-29 17:03:54 +00:00
commit 829e46beae
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;
}
+2 -2
View File
@@ -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;
+45 -1
View File
@@ -23,11 +23,18 @@ const refreshTokens = new Map<string, Grant>();
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<URLSearchParams> {
});
}
function readBody(req: IncomingMessage): Promise<Buffer> {
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<string, unknown>;
try {
body = JSON.parse((await readBody(req)).toString()) as Record<string, unknown>;
} 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) {
+2
View File
@@ -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" }) });
+33
View File
@@ -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<boolean> {
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);