Merge pull request 'A narrow route to another signed-in account, for its push subscription' (#52) from feat/other-accounts-push into main
This commit was merged in pull request #52.
This commit is contained in:
commit
13c9b8ef91
5 files changed
+174
-10
No files matched your search
@@ -17,6 +17,8 @@ process.env.MOCK_SECOND_USER = "[email protected]";
|
|||||||
process.env.MOCK_SECOND_PASS = "second-password";
|
process.env.MOCK_SECOND_PASS = "second-password";
|
||||||
process.env.MAIL_SERVER_URL = `http://127.0.0.1:${PORT}`;
|
process.env.MAIL_SERVER_URL = `http://127.0.0.1:${PORT}`;
|
||||||
process.env.APP_SECRET = "test-secret-for-accounts";
|
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 mock = await import("./mock/index.js");
|
||||||
const { createApp } = await import("./app.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(other.id, listed.id);
|
||||||
assert.equal(typeof other.unread, "number", JSON.stringify(res.body));
|
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("[email protected]", "first-password");
|
||||||
|
const added = await b.signIn("[email protected]", "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);
|
||||||
|
});
|
||||||
@@ -363,6 +363,35 @@ function liveOthers(c: Context): { cookie: string; session: LiveSession }[] {
|
|||||||
return out;
|
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<string, unknown>).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. */
|
/** inbuxa MA-8: Inbox unread counts of accounts not in front, briefly kept. */
|
||||||
const UNREAD_CACHE_MS = 60_000;
|
const UNREAD_CACHE_MS = 60_000;
|
||||||
const unreadCache = new Map<string, { unread: number | null; at: number }>();
|
const unreadCache = new Map<string, { unread: number | null; at: number }>();
|
||||||
@@ -806,6 +835,39 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
|||||||
return c.json({ accounts: answers });
|
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. */
|
/* inbuxa MA-B: bring another signed-in account to the front. */
|
||||||
api.post("/auth/accounts/:id/front", requireSession, async (c) => {
|
api.post("/auth/accounts/:id/front", requireSession, async (c) => {
|
||||||
const frontCookie = getCookie(c, config.cookieName)!;
|
const frontCookie = getCookie(c, config.cookieName)!;
|
||||||
|
|||||||
@@ -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");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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/<sessionId>/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 <T,>(method: string, args: Record<string, unknown>, using: string[]): Promise<T> => {
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -22,6 +22,15 @@ import type { GetResponse, Id, SetResponse } from "@/jmap/types";
|
|||||||
import { isDeviceTrusted } from "@/lib/storage";
|
import { isDeviceTrusted } from "@/lib/storage";
|
||||||
|
|
||||||
export const VAPID_CAP = "urn:ietf:params:jmap:webpush-vapid";
|
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 = <T>(method: string, args: Record<string, unknown>, using: string[]) => Promise<T>;
|
||||||
|
export const frontCall: JmapCall = (method, args, using) => client.call(method, args, using);
|
||||||
export const EMAILPUSH_CAP = "urn:ietf:params:jmap:emailpush";
|
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;
|
return at - now <= RENEW_WITHIN_MS;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function listSubscriptions(): Promise<JmapPushSubscription[]> {
|
export async function listSubscriptions(call: JmapCall = frontCall): Promise<JmapPushSubscription[]> {
|
||||||
const res = await client.call<GetResponse<JmapPushSubscription>>("PushSubscription/get", { ids: null }, [CAP.core, VAPID_CAP]);
|
const res = await call<GetResponse<JmapPushSubscription>>("PushSubscription/get", { ids: null }, [CAP.core, VAPID_CAP]);
|
||||||
return res.list;
|
return res.list;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function createSubscription(body: Record<string, unknown>): Promise<Id | null> {
|
export async function createSubscription(body: Record<string, unknown>, call: JmapCall = frontCall): Promise<Id | null> {
|
||||||
const res = await client.call<SetResponse<JmapPushSubscription>>(
|
const res = await call<SetResponse<JmapPushSubscription>>(
|
||||||
"PushSubscription/set",
|
"PushSubscription/set",
|
||||||
{ create: { s: body } },
|
{ create: { s: body } },
|
||||||
[CAP.core, VAPID_CAP, EMAILPUSH_CAP],
|
[CAP.core, VAPID_CAP, EMAILPUSH_CAP],
|
||||||
@@ -307,16 +316,16 @@ export async function createSubscription(body: Record<string, unknown>): Promise
|
|||||||
* Seven days is JMAP's ceiling and what Stalwart grants a new one; the server
|
* 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.
|
* may shorten what is asked for, and whatever it keeps is what counts.
|
||||||
*/
|
*/
|
||||||
export async function extendSubscription(id: Id, now: number = Date.now()): Promise<void> {
|
export async function extendSubscription(id: Id, now: number = Date.now(), call: JmapCall = frontCall): Promise<void> {
|
||||||
const expires = new Date(now + 7 * 24 * 60 * 60 * 1000).toISOString().replace(/\.\d+Z$/, "Z");
|
const expires = new Date(now + 7 * 24 * 60 * 60 * 1000).toISOString().replace(/\.\d+Z$/, "Z");
|
||||||
const res = await client.call<SetResponse<JmapPushSubscription>>("PushSubscription/set", { update: { [id]: { expires } } }, [CAP.core, VAPID_CAP]);
|
const res = await call<SetResponse<JmapPushSubscription>>("PushSubscription/set", { update: { [id]: { expires } } }, [CAP.core, VAPID_CAP]);
|
||||||
const err = res.notUpdated?.[id];
|
const err = res.notUpdated?.[id];
|
||||||
if (err) throw new PushSetError(String(err.type), String(err.description ?? err.type));
|
if (err) throw new PushSetError(String(err.type), String(err.description ?? err.type));
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function destroySubscriptions(ids: Id[]): Promise<void> {
|
export async function destroySubscriptions(ids: Id[], call: JmapCall = frontCall): Promise<void> {
|
||||||
if (!ids.length) return;
|
if (!ids.length) return;
|
||||||
await client.call<SetResponse<JmapPushSubscription>>("PushSubscription/set", { destroy: ids }, [CAP.core, VAPID_CAP]);
|
await call<SetResponse<JmapPushSubscription>>("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
|
* the client echoes it. A subscription left unverified looks registered and is
|
||||||
* silent, which is the confusing failure worth being explicit about.
|
* silent, which is the confusing failure worth being explicit about.
|
||||||
*/
|
*/
|
||||||
export async function verifySubscription(id: Id, verificationCode: string): Promise<void> {
|
export async function verifySubscription(id: Id, verificationCode: string, call: JmapCall = frontCall): Promise<void> {
|
||||||
const res = await client.call<SetResponse<JmapPushSubscription>>(
|
const res = await call<SetResponse<JmapPushSubscription>>(
|
||||||
"PushSubscription/set",
|
"PushSubscription/set",
|
||||||
{ update: { [id]: { verificationCode } } },
|
{ update: { [id]: { verificationCode } } },
|
||||||
[CAP.core, VAPID_CAP],
|
[CAP.core, VAPID_CAP],
|
||||||
|
|||||||
Reference in new issue
Block a user