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);