From 6b979c5ac7b47070f82c0402571d84a6ca3af253 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Mon, 5 Oct 2026 20:24:18 -0700 Subject: [PATCH] A narrow route to another signed-in account, for its push subscription First of three for notifications with the app closed, for every signed-in account (multi-account spec, MA-8 part 2). No behavior changes yet. A JMAP push subscription belongs to whoever signs the request, so an account that isn't in front can only have one registered, verified and renewed through its own session. POST /api/auth/accounts//jmap forwards to the mail server as that session, when it is one of this browser's other accounts, and only for PushSubscription/get and /set, Mailbox/get, and Email/set limited to keywords and mailboxIds updates (what a notification's Archive and Mark read need). Anything else is 403; the account in front, or a session this browser doesn't hold, is 404. Its OAuth token is renewed first if due. The browser already holds the session, so nothing new becomes reachable. On the web app side the push helpers (list, create, extend, destroy, verify) take a JMAP caller, defaulting to the account in front exactly as before, and lib/notify/otherAccount gives the caller for another account through the route. Tests: the allowlist, the route end to end with two accounts (allowed, refused, front and unknown sessions), and the caller. The accounts test file now raises LOGIN_RATE_LIMIT, since it signs in more often from one address than the default allows. typecheck, tests (web 1543, server 278) and build pass. --- server/src/accounts.test.ts | 36 +++++++++++ server/src/app.ts | 62 +++++++++++++++++++ .../__tests__/other-account-call.test.ts | 35 +++++++++++ web/src/lib/notify/otherAccount.ts | 22 +++++++ web/src/lib/notify/webpush.ts | 29 ++++++--- 5 files changed, 174 insertions(+), 10 deletions(-) create mode 100644 web/src/lib/notify/__tests__/other-account-call.test.ts create mode 100644 web/src/lib/notify/otherAccount.ts diff --git a/server/src/accounts.test.ts b/server/src/accounts.test.ts index c7519a2..e54428a 100644 --- a/server/src/accounts.test.ts +++ b/server/src/accounts.test.ts @@ -17,6 +17,8 @@ process.env.MOCK_SECOND_USER = "second@example.com"; process.env.MOCK_SECOND_PASS = "second-password"; process.env.MAIL_SERVER_URL = `http://127.0.0.1:${PORT}`; process.env.APP_SECRET = "test-secret-for-accounts"; +// Every test here signs in several times from one address +process.env.LOGIN_RATE_LIMIT = "100"; const mock = await import("./mock/index.js"); const { createApp } = await import("./app.js"); @@ -154,3 +156,37 @@ test("inbuxa MA-8: the accounts not in front report their Inbox unread count", a assert.equal(other.id, listed.id); assert.equal(typeof other.unread, "number", JSON.stringify(res.body)); }); + +test("inbuxa MA-8: only push, mailboxes and marking mail reach an account not in front", async () => { + const { otherAccountCallAllowed } = await import("./app.js"); + assert.equal(otherAccountCallAllowed(["PushSubscription/get", { ids: null }, "0"]), true); + assert.equal(otherAccountCallAllowed(["Mailbox/get", { accountId: "a" }, "0"]), true); + assert.equal(otherAccountCallAllowed(["Email/set", { accountId: "a", update: { m1: { "keywords/$seen": true } } }, "0"]), true); + assert.equal(otherAccountCallAllowed(["Email/set", { accountId: "a", update: { m1: { mailboxIds: { arch: true } } } }, "0"]), true); + // Anything else is refused + assert.equal(otherAccountCallAllowed(["Email/get", { accountId: "a" }, "0"]), false); + assert.equal(otherAccountCallAllowed(["Email/set", { accountId: "a", destroy: ["m1"] }, "0"]), false); + assert.equal(otherAccountCallAllowed(["Email/set", { accountId: "a", create: { x: {} } }, "0"]), false); + assert.equal(otherAccountCallAllowed(["Email/set", { accountId: "a", update: { m1: { subject: "x" } } }, "0"]), false); + assert.equal(otherAccountCallAllowed(["EmailSubmission/set", {}, "0"]), false); + + const b = new Browser(); + await b.signIn("first@example.com", "first-password"); + const added = await b.signIn("second@example.com", "second-password", true); + assert.equal(added.status, 200, JSON.stringify(added.body)); + const other = (await b.accounts()).find((a) => !a.front)!; + const ok = await b.call(`/api/auth/accounts/${other.id}/jmap`, { + method: "POST", + body: { using: ["urn:ietf:params:jmap:core"], methodCalls: [["PushSubscription/get", { ids: null }, "0"]] }, + }); + assert.equal(ok.status, 200, JSON.stringify(ok.body)); + assert.equal(ok.body.methodResponses[0][0], "PushSubscription/get"); + const refused = await b.call(`/api/auth/accounts/${other.id}/jmap`, { + method: "POST", + body: { using: [], methodCalls: [["Email/get", { accountId: "x", ids: null }, "0"]] }, + }); + assert.equal(refused.status, 403); + // The account in front isn't reached this way, nor a session not held here + const front = (await b.accounts()).find((a) => a.front)!; + assert.equal((await b.call(`/api/auth/accounts/${front.id}/jmap`, { method: "POST", body: { methodCalls: [] } })).status, 404); +}); diff --git a/server/src/app.ts b/server/src/app.ts index 790e98f..2335c52 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -363,6 +363,35 @@ function liveOthers(c: Context): { cookie: string; session: LiveSession }[] { return out; } +/* + * inbuxa MA-8: what may be asked of a signed-in account that isn't in front. + * + * Its push subscription has to be registered, verified and renewed through + * its own session -- a JMAP push subscription belongs to whoever signs the + * request -- and a notification's Archive and Mark read act on its mail. Those + * four methods, and for Email/set only changes to keywords and mailboxes: the + * browser already holds the session, so this reaches nothing new, but it is + * kept to what the notifications need. + */ +const OTHER_ACCOUNT_METHODS = new Set(["PushSubscription/get", "PushSubscription/set", "Mailbox/get", "Email/set"]); + +export function otherAccountCallAllowed(call: unknown): boolean { + if (!Array.isArray(call) || call.length !== 3) return false; + const [method, args] = call as [unknown, unknown, unknown]; + if (typeof method !== "string" || !OTHER_ACCOUNT_METHODS.has(method)) return false; + if (typeof args !== "object" || args === null) return false; + if (method !== "Email/set") return true; + const set = args as { create?: unknown; destroy?: unknown; update?: unknown }; + if (set.create !== undefined || set.destroy !== undefined) return false; + if (typeof set.update !== "object" || set.update === null) return false; + return Object.values(set.update as Record).every( + (patch) => + typeof patch === "object" && + patch !== null && + Object.keys(patch).every((k) => k === "keywords" || k === "mailboxIds" || k.startsWith("keywords/") || k.startsWith("mailboxIds/")), + ); +} + /** inbuxa MA-8: Inbox unread counts of accounts not in front, briefly kept. */ const UNREAD_CACHE_MS = 60_000; const unreadCache = new Map(); @@ -806,6 +835,39 @@ export function createApp(basePath = config.basePath): Hono { return c.json({ accounts: answers }); }); + /* inbuxa MA-8: a narrow JMAP route to a signed-in account not in front; see OTHER_ACCOUNT_METHODS. */ + api.post("/auth/accounts/:id/jmap", requireSession, async (c) => { + const other = liveOthers(c).find((o) => o.session.id === c.req.param("id")); + if (!other) return c.json({ error: "not_found" }, 404); + let body: { using?: unknown; methodCalls?: unknown }; + try { + body = await c.req.json(); + } catch { + return c.json({ error: "bad_request" }, 400); + } + const calls = body.methodCalls; + if (!Array.isArray(calls) || calls.length === 0 || calls.length > 16 || !calls.every(otherAccountCallAllowed)) { + return c.json({ error: "forbidden", message: "Only push subscriptions, mailboxes and marking mail can be reached in another account." }, 403); + } + const using = Array.isArray(body.using) ? body.using.filter((u): u is string => typeof u === "string") : []; + let session: LiveSession | null = other.session; + if (session.tokens && needsRefresh(session.tokens)) session = await refreshSession(other.cookie, session); + if (!session) return c.json({ error: "unauthenticated" }, 401); + try { + const upstream = await getUpstreamSession(session.id, session.authorization, upstreamFor(session.username)); + const res = await fetch(absoluteUpstream(upstream.apiUrl, upstream.baseUrl), { + method: "POST", + headers: { authorization: session.authorization, "content-type": "application/json", accept: "application/json" }, + body: JSON.stringify({ using, methodCalls: calls }), + signal: AbortSignal.timeout(config.upstreamTimeout), + }); + if (res.status === 401 || res.status === 403) return c.json({ error: "unauthenticated" }, 401); + return c.json(await res.json(), res.ok ? 200 : 502); + } catch (err) { + return upstreamFailure(c, err); + } + }); + /* inbuxa MA-B: bring another signed-in account to the front. */ api.post("/auth/accounts/:id/front", requireSession, async (c) => { const frontCookie = getCookie(c, config.cookieName)!; diff --git a/web/src/lib/notify/__tests__/other-account-call.test.ts b/web/src/lib/notify/__tests__/other-account-call.test.ts new file mode 100644 index 0000000..6950366 --- /dev/null +++ b/web/src/lib/notify/__tests__/other-account-call.test.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { otherAccountCall } from "@/lib/notify/otherAccount"; +import { listSubscriptions } from "@/lib/notify/webpush"; + +/** inbuxa MA-8: push calls made as an account that isn't in front. */ + +afterEach(() => vi.unstubAllGlobals()); + +function stub(response: unknown) { + const seen: { url: string; body: any }[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (url: string, init?: RequestInit) => { + seen.push({ url: String(url), body: JSON.parse(String(init?.body ?? "{}")) }); + return { ok: true, status: 200, json: async () => response, text: async () => JSON.stringify(response) } as Response; + }), + ); + return seen; +} + +describe("otherAccountCall", () => { + it("goes through that account's route and answers the method's result", async () => { + const seen = stub({ methodResponses: [["PushSubscription/get", { list: [{ id: "p1", deviceClientId: "d" }] }, "0"]] }); + const subs = await listSubscriptions(otherAccountCall("sess-2")); + expect(subs.map((s) => s.id)).toEqual(["p1"]); + expect(seen[0]!.url).toContain("/api/auth/accounts/sess-2/jmap"); + expect(seen[0]!.body.methodCalls[0][0]).toBe("PushSubscription/get"); + expect(seen[0]!.body.using).toContain("urn:ietf:params:jmap:core"); + }); + + it("turns a JMAP error into a thrown one", async () => { + stub({ methodResponses: [["error", { type: "forbidden" }, "0"]] }); + await expect(listSubscriptions(otherAccountCall("sess-2"))).rejects.toThrow("forbidden"); + }); +}); diff --git a/web/src/lib/notify/otherAccount.ts b/web/src/lib/notify/otherAccount.ts new file mode 100644 index 0000000..2e2c10d --- /dev/null +++ b/web/src/lib/notify/otherAccount.ts @@ -0,0 +1,22 @@ +/** + * inbuxa MA-8: JMAP calls made as a signed-in account that isn't in front, + * through the webmail server's narrow route for it + * (POST /api/auth/accounts//jmap). The server allows only what + * notifications need: push subscriptions, mailboxes, and marking or filing + * mail. + */ +import { apiFetch, CAP } from "@/jmap/client"; +import type { JmapCall } from "@/lib/notify/webpush"; + +export function otherAccountCall(sessionId: string): JmapCall { + return async (method: string, args: Record, using: string[]): Promise => { + const res = await apiFetch<{ methodResponses?: [string, unknown, string][] }>( + `/api/auth/accounts/${encodeURIComponent(sessionId)}/jmap`, + { method: "POST", body: JSON.stringify({ using: [...new Set([CAP.core, ...using])], methodCalls: [[method, args, "0"]] }) }, + ); + const [name, out] = res.methodResponses?.[0] ?? []; + if (!name) throw new Error("The mail server sent no response."); + if (name === "error") throw new Error(String((out as { type?: string } | undefined)?.type ?? "error")); + return out as T; + }; +} diff --git a/web/src/lib/notify/webpush.ts b/web/src/lib/notify/webpush.ts index 5c6970a..8fab8d1 100644 --- a/web/src/lib/notify/webpush.ts +++ b/web/src/lib/notify/webpush.ts @@ -22,6 +22,15 @@ import type { GetResponse, Id, SetResponse } from "@/jmap/types"; import { isDeviceTrusted } from "@/lib/storage"; export const VAPID_CAP = "urn:ietf:params:jmap:webpush-vapid"; + +/** + * Who a push call is made as (inbuxa MA-8). A JMAP push subscription belongs + * to whoever signs the request, so registering one for an account that isn't + * in front goes through that account's own session (lib/notify/otherAccount). + * Everything here defaults to the account in front. + */ +export type JmapCall = (method: string, args: Record, using: string[]) => Promise; +export const frontCall: JmapCall = (method, args, using) => client.call(method, args, using); export const EMAILPUSH_CAP = "urn:ietf:params:jmap:emailpush"; /** @@ -285,13 +294,13 @@ export function needsRenewal(subs: JmapPushSubscription[], deviceId: string, now return at - now <= RENEW_WITHIN_MS; } -export async function listSubscriptions(): Promise { - const res = await client.call>("PushSubscription/get", { ids: null }, [CAP.core, VAPID_CAP]); +export async function listSubscriptions(call: JmapCall = frontCall): Promise { + const res = await call>("PushSubscription/get", { ids: null }, [CAP.core, VAPID_CAP]); return res.list; } -export async function createSubscription(body: Record): Promise { - const res = await client.call>( +export async function createSubscription(body: Record, call: JmapCall = frontCall): Promise { + const res = await call>( "PushSubscription/set", { create: { s: body } }, [CAP.core, VAPID_CAP, EMAILPUSH_CAP], @@ -307,16 +316,16 @@ export async function createSubscription(body: Record): Promise * Seven days is JMAP's ceiling and what Stalwart grants a new one; the server * may shorten what is asked for, and whatever it keeps is what counts. */ -export async function extendSubscription(id: Id, now: number = Date.now()): Promise { +export async function extendSubscription(id: Id, now: number = Date.now(), call: JmapCall = frontCall): Promise { const expires = new Date(now + 7 * 24 * 60 * 60 * 1000).toISOString().replace(/\.\d+Z$/, "Z"); - const res = await client.call>("PushSubscription/set", { update: { [id]: { expires } } }, [CAP.core, VAPID_CAP]); + const res = await call>("PushSubscription/set", { update: { [id]: { expires } } }, [CAP.core, VAPID_CAP]); const err = res.notUpdated?.[id]; if (err) throw new PushSetError(String(err.type), String(err.description ?? err.type)); } -export async function destroySubscriptions(ids: Id[]): Promise { +export async function destroySubscriptions(ids: Id[], call: JmapCall = frontCall): Promise { if (!ids.length) return; - await client.call>("PushSubscription/set", { destroy: ids }, [CAP.core, VAPID_CAP]); + await call>("PushSubscription/set", { destroy: ids }, [CAP.core, VAPID_CAP]); } /** @@ -353,8 +362,8 @@ export function rememberEndpoint(endpoint: string | null): void { * the client echoes it. A subscription left unverified looks registered and is * silent, which is the confusing failure worth being explicit about. */ -export async function verifySubscription(id: Id, verificationCode: string): Promise { - const res = await client.call>( +export async function verifySubscription(id: Id, verificationCode: string, call: JmapCall = frontCall): Promise { + const res = await call>( "PushSubscription/set", { update: { [id]: { verificationCode } } }, [CAP.core, VAPID_CAP], -- 2.54.0