Confirm a typed password without replaying it over JMAP #30
No files matched your search
+4
-5
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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" }) });
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user