Merge pull request 'Account switcher: more than one account signed in at once' (#49) from feat/account-switcher into main
This commit was merged in pull request #49.
This commit is contained in:
commit
351a5aa01d
10 files changed
+716
-9
No files matched your search
@@ -0,0 +1,141 @@
|
||||
import { test, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
/**
|
||||
* inbuxa MA-B: more than one account signed in in one browser. The session
|
||||
* cookie is the account in front; the others ride in `<name>_more`. Adding
|
||||
* signs a second account in beside the first, switching swaps them, signing
|
||||
* out ends only the one in front, and an organization that doesn't allow it
|
||||
* keeps adding off.
|
||||
*/
|
||||
|
||||
const PORT = 18801;
|
||||
process.env.MOCK_PORT = String(PORT);
|
||||
process.env.MOCK_USER = "[email protected]";
|
||||
process.env.MOCK_PASS = "first-password";
|
||||
process.env.MOCK_SECOND_USER = "[email protected]";
|
||||
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";
|
||||
|
||||
const mock = await import("./mock/index.js");
|
||||
const { createApp } = await import("./app.js");
|
||||
const { config } = await import("./config.js");
|
||||
const { MAX_ACCOUNTS, parseOthers, serializeOthers } = await import("./accounts.js");
|
||||
|
||||
const app = createApp();
|
||||
const FRONT = config.cookieName;
|
||||
const MORE = `${config.cookieName}_more`;
|
||||
|
||||
after(() => {
|
||||
(mock as { server?: { close(): void } }).server?.close();
|
||||
});
|
||||
|
||||
/** A browser's cookie jar, as far as these two cookies go. */
|
||||
class Browser {
|
||||
jar = new Map<string, string>();
|
||||
|
||||
private take(res: Response) {
|
||||
for (const line of res.headers.getSetCookie()) {
|
||||
const [pair, ...attrs] = line.split(";");
|
||||
const at = pair!.indexOf("=");
|
||||
const name = pair!.slice(0, at).trim();
|
||||
const value = pair!.slice(at + 1).trim();
|
||||
const expired = attrs.some((a) => /max-age=0/i.test(a) || /expires=thu, 01 jan 1970/i.test(a));
|
||||
if (expired || !value) this.jar.delete(name);
|
||||
else this.jar.set(name, value);
|
||||
}
|
||||
}
|
||||
|
||||
async call(path: string, init: { method?: string; body?: unknown } = {}): Promise<{ status: number; body: any }> {
|
||||
const cookie = [...this.jar].map(([k, v]) => `${k}=${v}`).join("; ");
|
||||
const res = await app.request(path, {
|
||||
method: init.method ?? "GET",
|
||||
headers: { "content-type": "application/json", "x-requested-with": "ihasmail", ...(cookie ? { cookie } : {}) },
|
||||
...(init.body !== undefined ? { body: JSON.stringify(init.body) } : {}),
|
||||
});
|
||||
this.take(res);
|
||||
const text = await res.text();
|
||||
return { status: res.status, body: text ? JSON.parse(text) : null };
|
||||
}
|
||||
|
||||
signIn(username: string, password: string, add = false) {
|
||||
return this.call("/api/auth/login", { method: "POST", body: { username, password, ...(add ? { add: true } : {}) } });
|
||||
}
|
||||
|
||||
async accounts(): Promise<{ username: string; front: boolean; id: string }[]> {
|
||||
const res = await this.call("/api/auth/accounts");
|
||||
assert.equal(res.status, 200, JSON.stringify(res.body));
|
||||
return res.body.accounts;
|
||||
}
|
||||
}
|
||||
|
||||
test("the cookie list keeps only well-formed session cookies, at most one fewer than the cap", () => {
|
||||
const good = "abcdefghij.ABCDEFGHIJKLMN";
|
||||
assert.deepEqual(parseOthers(`${good}~not a cookie~${good}`), [good]);
|
||||
const many = Array.from({ length: 9 }, (_, i) => `abcdefgh${i}x.ABCDEFGHIJKLMN`);
|
||||
assert.equal(parseOthers(many.join("~")).length, MAX_ACCOUNTS - 1);
|
||||
assert.equal(serializeOthers(["bad", good]), good);
|
||||
});
|
||||
|
||||
test("a second account joins the first, and switching swaps them", async () => {
|
||||
const b = new Browser();
|
||||
assert.equal((await b.signIn("[email protected]", "first-password")).status, 200);
|
||||
assert.deepEqual((await b.accounts()).map((a) => a.username), ["[email protected]"]);
|
||||
const first = b.jar.get(FRONT);
|
||||
|
||||
const added = await b.signIn("[email protected]", "second-password", true);
|
||||
assert.equal(added.status, 200, JSON.stringify(added.body));
|
||||
assert.equal(added.body.added, true);
|
||||
assert.equal(b.jar.get(MORE), first, "the first account moved beside the new one");
|
||||
let accounts = await b.accounts();
|
||||
assert.deepEqual(accounts.map((a) => [a.username, a.front]), [["[email protected]", true], ["[email protected]", false]]);
|
||||
|
||||
// The same account again is not a second copy
|
||||
await b.signIn("[email protected]", "first-password", true);
|
||||
accounts = await b.accounts();
|
||||
assert.equal(accounts.length, 2);
|
||||
assert.equal(accounts[0]!.username, "[email protected]", "it came to the front instead");
|
||||
|
||||
// Switch back
|
||||
const second = accounts.find((a) => !a.front)!;
|
||||
assert.equal((await b.call(`/api/auth/accounts/${second.id}/front`, { method: "POST" })).status, 200);
|
||||
assert.equal((await b.accounts())[0]!.username, "[email protected]");
|
||||
|
||||
// Signing out ends only the one in front; the other comes forward
|
||||
const out = await b.call("/api/auth/logout", { method: "POST" });
|
||||
assert.equal(out.body.next, true);
|
||||
assert.deepEqual((await b.accounts()).map((a) => a.username), ["[email protected]"]);
|
||||
|
||||
// Sign out of all
|
||||
await b.signIn("[email protected]", "second-password", true);
|
||||
assert.equal((await b.accounts()).length, 2);
|
||||
await b.call("/api/auth/logout-all", { method: "POST" });
|
||||
assert.equal(b.jar.has(FRONT), false);
|
||||
assert.equal(b.jar.has(MORE), false);
|
||||
assert.equal((await b.call("/api/auth/accounts")).status, 401);
|
||||
});
|
||||
|
||||
test("an account in front can't switch to one it doesn't hold", async () => {
|
||||
const b = new Browser();
|
||||
await b.signIn("[email protected]", "first-password");
|
||||
assert.equal((await b.call("/api/auth/accounts/not-a-session/front", { method: "POST" })).status, 404);
|
||||
});
|
||||
|
||||
test("an organization that doesn't allow it keeps adding off", async () => {
|
||||
const b = new Browser();
|
||||
await b.signIn("[email protected]", "first-password");
|
||||
process.env.MOCK_NO_ADD_ACCOUNTS = "1";
|
||||
try {
|
||||
// The cached upstream session is a minute old at most; ask afresh
|
||||
await b.call("/api/auth/session?refresh=1");
|
||||
const res = await b.call("/api/auth/accounts");
|
||||
assert.equal(res.body.canAdd, false);
|
||||
const added = await b.signIn("[email protected]", "second-password", true);
|
||||
assert.equal(added.status, 403);
|
||||
assert.equal(added.body.error, "add_not_allowed");
|
||||
assert.deepEqual((await b.accounts()).map((a) => a.username), ["[email protected]"], "the front stayed");
|
||||
} finally {
|
||||
delete process.env.MOCK_NO_ADD_ACCOUNTS;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* More than one signed-in account in a browser (multi-account spec, MA-B).
|
||||
*
|
||||
* The session cookie is unchanged: it is the account in front, and every
|
||||
* request is answered with it, so nothing else in the server has to know
|
||||
* there may be others. The others ride in a second cookie, `<name>_more`: a
|
||||
* list of their own session cookies, each `id.secret` exactly as the front one
|
||||
* is. Switching swaps one of them into front; adding moves the front one into
|
||||
* the list. Each session stays its own -- its own sealed credential, its own
|
||||
* expiry, its own "this is my device" -- and nothing about one can be read
|
||||
* through another.
|
||||
*
|
||||
* At most `MAX_ACCOUNTS` in all, all on the same mail server (MA-9), and only
|
||||
* while both accounts' organizations allow it (`addAccounts`, MA-C).
|
||||
*/
|
||||
import type { UpstreamSession } from "./upstream.js";
|
||||
|
||||
export const MAX_ACCOUNTS = 5;
|
||||
|
||||
/** A session cookie's shape: `id.secret`, both base64url. Anything else is dropped. */
|
||||
const COOKIE_SHAPE = /^[A-Za-z0-9_-]{8,128}\.[A-Za-z0-9_-]{8,256}$/;
|
||||
const SEP = "~";
|
||||
|
||||
export function parseOthers(value: string | undefined): string[] {
|
||||
if (!value) return [];
|
||||
const seen = new Set<string>();
|
||||
const out: string[] = [];
|
||||
for (const part of value.split(SEP)) {
|
||||
if (!COOKIE_SHAPE.test(part) || seen.has(part)) continue;
|
||||
seen.add(part);
|
||||
out.push(part);
|
||||
if (out.length >= MAX_ACCOUNTS - 1) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function serializeOthers(cookies: string[]): string {
|
||||
return cookies.filter((c) => COOKIE_SHAPE.test(c)).slice(0, MAX_ACCOUNTS - 1).join(SEP);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the account behind `upstream` may have other accounts beside it:
|
||||
* `addAccounts` on its own account's `urn:inbuxa:jmap` capability. A server
|
||||
* that doesn't say (an older one, or not inbuxa) allows it, as before.
|
||||
*/
|
||||
export function mayAddAccounts(upstream: UpstreamSession): boolean {
|
||||
const primary = upstream.primaryAccounts?.["urn:ietf:params:jmap:mail"] ?? Object.keys(upstream.accounts ?? {})[0];
|
||||
const account = primary ? (upstream.accounts?.[primary] as { accountCapabilities?: Record<string, unknown> } | undefined) : undefined;
|
||||
const inbuxa = account?.accountCapabilities?.["urn:inbuxa:jmap"] as { addAccounts?: unknown } | undefined;
|
||||
return inbuxa?.addAccounts !== false;
|
||||
}
|
||||
|
||||
/** Why an account can't be added, in words for the person. */
|
||||
export const ADD_REFUSED: Record<string, string> = {
|
||||
add_full: `You can have at most ${MAX_ACCOUNTS} accounts open here.`,
|
||||
add_not_allowed: "Your organization doesn't allow adding other accounts here.",
|
||||
add_other_server: "That account is on another mail server. Only accounts on this server can be added.",
|
||||
};
|
||||
+159
-2
@@ -45,6 +45,7 @@ import { SignInError, finish as finishSignIn, needsRefresh, oauthEnabled, passwo
|
||||
import { icsProxyHandler } from "./icsproxy.js";
|
||||
import { staticHandler } from "./static.js";
|
||||
import { mailNode, webmailNode } from "./nodes.js";
|
||||
import { ADD_REFUSED, MAX_ACCOUNTS, mayAddAccounts, parseOthers, serializeOthers } from "./accounts.js";
|
||||
|
||||
type Env = { Variables: { session: LiveSession } };
|
||||
|
||||
@@ -330,6 +331,93 @@ function setSessionCookie(c: Context, value: string, remember: boolean) {
|
||||
});
|
||||
}
|
||||
|
||||
/*
|
||||
* inbuxa MA-B: the other signed-in accounts, beside the one in front. See
|
||||
* accounts.ts. Kept across a browser restart only when every account in it
|
||||
* would be (MA-7).
|
||||
*/
|
||||
const OTHERS_COOKIE = `${config.cookieName}_more`;
|
||||
|
||||
function setOthersCookie(c: Context, cookies: string[]) {
|
||||
if (!cookies.length) {
|
||||
deleteCookie(c, OTHERS_COOKIE, { path: cookiePath });
|
||||
return;
|
||||
}
|
||||
const remember = cookies.every((cookie) => sessions.resolve(cookie)?.remember === true);
|
||||
setCookie(c, OTHERS_COOKIE, serializeOthers(cookies), {
|
||||
httpOnly: true,
|
||||
sameSite: "Lax",
|
||||
secure: isSecureRequest(c),
|
||||
path: cookiePath,
|
||||
...(remember ? { maxAge: config.sessionRememberTtl } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
/** The other accounts still signed in, in their order; ended ones are left out. */
|
||||
function liveOthers(c: Context): { cookie: string; session: LiveSession }[] {
|
||||
const out: { cookie: string; session: LiveSession }[] = [];
|
||||
for (const cookie of parseOthers(getCookie(c, OTHERS_COOKIE))) {
|
||||
const session = sessions.resolve(cookie);
|
||||
if (session) out.push({ cookie, session });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Whether the account in front may have more beside it, or why not. */
|
||||
async function addRefusal(c: Context, front: LiveSession): Promise<string | null> {
|
||||
if (1 + liveOthers(c).length >= MAX_ACCOUNTS) return "add_full";
|
||||
try {
|
||||
const upstream = await getUpstreamSession(front.id, front.authorization, upstreamFor(front.username));
|
||||
if (!mayAddAccounts(upstream)) return "add_not_allowed";
|
||||
} catch {
|
||||
return "add_not_allowed";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A session just signed in to be added beside the one in front (MA-B). It
|
||||
* comes to the front and the old front joins the others; or, refused, it is
|
||||
* ended and the front stays. Returns the refusal, or null.
|
||||
*/
|
||||
async function joinAccount(
|
||||
c: Context,
|
||||
created: { cookie: string; session: LiveSession },
|
||||
upstream: Awaited<ReturnType<typeof fetchUpstreamSession>>,
|
||||
): Promise<string | null> {
|
||||
const frontCookie = getCookie(c, config.cookieName);
|
||||
const front = sessions.resolve(frontCookie);
|
||||
if (!front || !frontCookie) {
|
||||
// Nobody in front any more: an ordinary sign-in
|
||||
setSessionCookie(c, created.cookie, created.session.remember);
|
||||
return null;
|
||||
}
|
||||
const others = liveOthers(c);
|
||||
const end = (code: string | null) => {
|
||||
sessions.destroy(created.session.id);
|
||||
forgetUpstreamSession(created.session.id);
|
||||
return code;
|
||||
};
|
||||
if (upstreamFor(created.session.username) !== upstreamFor(front.username)) return end("add_other_server");
|
||||
// Both organizations must allow it
|
||||
if (!mayAddAccounts(upstream)) return end("add_not_allowed");
|
||||
const frontRefusal = await addRefusal(c, front);
|
||||
if (frontRefusal === "add_not_allowed") return end(frontRefusal);
|
||||
// Already open: that one comes to the front instead of a second copy
|
||||
if (created.session.account === front.account) return end(null);
|
||||
const existing = others.find((o) => o.session.account === created.session.account);
|
||||
if (existing) {
|
||||
end(null);
|
||||
setSessionCookie(c, existing.cookie, existing.session.remember);
|
||||
setOthersCookie(c, [frontCookie, ...others.filter((o) => o !== existing).map((o) => o.cookie)]);
|
||||
return null;
|
||||
}
|
||||
if (1 + others.length >= MAX_ACCOUNTS) return end("add_full");
|
||||
setSessionCookie(c, created.cookie, created.session.remember);
|
||||
setOthersCookie(c, [frontCookie, ...others.map((o) => o.cookie)]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function upstreamFailure(c: Context, err: unknown) {
|
||||
if (err instanceof UpstreamError) {
|
||||
return c.json({ error: err.status === 401 ? "invalid_credentials" : "upstream_error", message: err.message }, err.status as 401 | 502);
|
||||
@@ -406,8 +494,19 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
return c.redirect(`${basePath}/?signin_error=rate_limited`, 302);
|
||||
}
|
||||
const username = (c.req.query("username") ?? "").trim().slice(0, 320);
|
||||
// inbuxa MA-B: another account beside the one in front, if it may have one
|
||||
const front = c.req.query("add") === "1" ? sessions.resolve(getCookie(c, config.cookieName)) : null;
|
||||
if (front) {
|
||||
const refused = await addRefusal(c, front);
|
||||
if (refused) return c.redirect(`${basePath}/?account_error=${refused}`, 302);
|
||||
}
|
||||
try {
|
||||
const { location, state } = await startSignIn({ username, base: upstreamFor(username), remember: c.req.query("remember") === "1" });
|
||||
const { location, state } = await startSignIn({
|
||||
username,
|
||||
base: upstreamFor(username),
|
||||
remember: c.req.query("remember") === "1",
|
||||
adding: front !== null,
|
||||
});
|
||||
setCookie(c, OAUTH_STATE_COOKIE, state, { httpOnly: true, sameSite: "Lax", secure: isSecureRequest(c), path: `${basePath}/api/auth`, maxAge: 600 });
|
||||
return c.redirect(location, 302);
|
||||
} catch (err) {
|
||||
@@ -452,6 +551,11 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
userAgent: c.req.header("user-agent") ?? "",
|
||||
ip: clientIp(c),
|
||||
});
|
||||
if (result.adding) {
|
||||
const refused = await joinAccount(c, { cookie, session }, upstream);
|
||||
if (refused) return c.redirect(`${basePath}/?account_error=${refused}`, 302);
|
||||
return c.redirect(`${basePath}/`, 302);
|
||||
}
|
||||
setSessionCookie(c, cookie, session.remember);
|
||||
const mailAccount = upstream.primaryAccounts?.["urn:ietf:params:jmap:mail"];
|
||||
if (mailAccount) pushPrepare(session.username, mailAccount, pushCredential(session));
|
||||
@@ -473,7 +577,7 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
c.header("Retry-After", String(loginFloodLimiter.retryAfterSeconds(rateIp)));
|
||||
return c.json({ error: "rate_limited", message: "Too many login attempts. Please wait and try again." }, 429);
|
||||
}
|
||||
let body: { username?: string; password?: string; totp?: string; remember?: boolean };
|
||||
let body: { username?: string; password?: string; totp?: string; remember?: boolean; add?: boolean };
|
||||
try {
|
||||
body = await c.req.json();
|
||||
} catch {
|
||||
@@ -536,6 +640,12 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
userAgent: c.req.header("user-agent") ?? "",
|
||||
ip,
|
||||
});
|
||||
// inbuxa MA-B: beside the account in front, when that's what was asked
|
||||
if (body.add && sessions.resolve(getCookie(c, config.cookieName))) {
|
||||
const refused = await joinAccount(c, { cookie, session }, upstream);
|
||||
if (refused) return c.json({ error: refused, message: ADD_REFUSED[refused] }, 403);
|
||||
return c.json({ ok: true, added: true });
|
||||
}
|
||||
setSessionCookie(c, cookie, session.remember);
|
||||
// Start the account's push subscription now, so it is usually verified
|
||||
// by the time the browser opens its stream. See push.ts.
|
||||
@@ -606,7 +716,54 @@ export function createApp(basePath = config.basePath): Hono<Env> {
|
||||
sessions.destroy(session.id);
|
||||
forgetUpstreamSession(session.id);
|
||||
}
|
||||
// inbuxa MA-B: only this account ends; the next one comes to the front
|
||||
const [next, ...rest] = liveOthers(c);
|
||||
if (next) {
|
||||
setSessionCookie(c, next.cookie, next.session.remember);
|
||||
setOthersCookie(c, rest.map((o) => o.cookie));
|
||||
return c.json({ ok: true, next: true });
|
||||
}
|
||||
deleteCookie(c, config.cookieName, { path: cookiePath });
|
||||
deleteCookie(c, OTHERS_COOKIE, { path: cookiePath });
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
/* inbuxa MA-B: every account signed in here ends. */
|
||||
api.post("/auth/logout-all", async (c) => {
|
||||
const front = sessions.resolve(getCookie(c, config.cookieName));
|
||||
for (const session of [front, ...liveOthers(c).map((o) => o.session)]) {
|
||||
if (!session) continue;
|
||||
sessions.destroy(session.id);
|
||||
forgetUpstreamSession(session.id);
|
||||
}
|
||||
deleteCookie(c, config.cookieName, { path: cookiePath });
|
||||
deleteCookie(c, OTHERS_COOKIE, { path: cookiePath });
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
/* inbuxa MA-B: the accounts signed in here, the one in front first, and whether one more may be added. */
|
||||
api.get("/auth/accounts", requireSession, async (c) => {
|
||||
const front = c.get("session");
|
||||
const others = liveOthers(c);
|
||||
if (others.length !== parseOthers(getCookie(c, OTHERS_COOKIE)).length) {
|
||||
setOthersCookie(c, others.map((o) => o.cookie));
|
||||
}
|
||||
const canAdd = (await addRefusal(c, front)) === null;
|
||||
return c.json({
|
||||
accounts: [front, ...others.map((o) => o.session)].map((s, i) => ({ id: s.id, username: s.username, front: i === 0 })),
|
||||
canAdd,
|
||||
max: MAX_ACCOUNTS,
|
||||
});
|
||||
});
|
||||
|
||||
/* 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)!;
|
||||
const others = liveOthers(c);
|
||||
const chosen = others.find((o) => o.session.id === c.req.param("id"));
|
||||
if (!chosen) return c.json({ error: "not_found" }, 404);
|
||||
setSessionCookie(c, chosen.cookie, chosen.session.remember);
|
||||
setOthersCookie(c, [frontCookie, ...others.filter((o) => o !== chosen).map((o) => o.cookie)]);
|
||||
return c.json({ ok: true });
|
||||
});
|
||||
|
||||
|
||||
@@ -23,9 +23,25 @@ function unauthorized(res: ServerResponse) {
|
||||
res.end(JSON.stringify({ type: "about:blank", status: 401, title: "Unauthorized" }));
|
||||
}
|
||||
|
||||
/*
|
||||
* inbuxa MA-B: an optional second user, so the account switcher has two real
|
||||
* accounts to move between. It signs in with a password only, and reads the
|
||||
* same mailbox: what the tests look at is who the session says it is.
|
||||
*/
|
||||
const SECOND_USER = process.env.MOCK_SECOND_USER;
|
||||
const SECOND_PASS = process.env.MOCK_SECOND_PASS;
|
||||
|
||||
/** Who a Basic header signs in as, when it is the second user. */
|
||||
function secondUser(req: IncomingMessage): boolean {
|
||||
const h = req.headers.authorization ?? "";
|
||||
if (!SECOND_USER || !h.startsWith("Basic ")) return false;
|
||||
return Buffer.from(h.slice(6), "base64").toString() === `${SECOND_USER}:${SECOND_PASS}`;
|
||||
}
|
||||
|
||||
function checkAuth(req: IncomingMessage): boolean {
|
||||
const h = req.headers.authorization ?? "";
|
||||
if (checkBearer(h)) return true;
|
||||
if (secondUser(req)) return true;
|
||||
if (!h.startsWith("Basic ") || basicRefused) return false;
|
||||
const raw = Buffer.from(h.slice(6), "base64").toString();
|
||||
const sep = raw.indexOf(":");
|
||||
@@ -83,7 +99,14 @@ export const server = createServer(async (req, res) => {
|
||||
if (!checkAuth(req)) return unauthorized(res);
|
||||
if (url.pathname === "/.well-known/jmap" || url.pathname === "/jmap/session") {
|
||||
res.writeHead(200, { "content-type": "application/json" });
|
||||
return res.end(JSON.stringify(session()));
|
||||
const answer = session() as ReturnType<typeof session> & { username: string };
|
||||
if (secondUser(req)) answer.username = SECOND_USER!;
|
||||
// MA-C: an organization that doesn't allow adding accounts
|
||||
if (process.env.MOCK_NO_ADD_ACCOUNTS === "1") {
|
||||
const own = answer.accounts[ACCOUNT] as { accountCapabilities: Record<string, unknown> };
|
||||
own.accountCapabilities = { ...own.accountCapabilities, "urn:inbuxa:jmap": { addAccounts: false } };
|
||||
}
|
||||
return res.end(JSON.stringify(answer));
|
||||
}
|
||||
// The account info endpoint; the only place a server reports its edition.
|
||||
if (url.pathname === "/api/account" && req.method === "GET") {
|
||||
|
||||
@@ -217,3 +217,23 @@ test("push keeps a credential that renews itself", async () => {
|
||||
assert.notEqual(second, first, "a fresh access token");
|
||||
assert.match(second, /^Bearer mock-at-/);
|
||||
});
|
||||
|
||||
test("inbuxa MA-B: adding an account asks the server's page to sign in again", async () => {
|
||||
// With nobody in front, add=1 is an ordinary sign-in
|
||||
let res = await call("/api/auth/oauth/[email protected]&add=1");
|
||||
assert.equal(new URL(res.headers.get("location")!).searchParams.has("prompt"), false);
|
||||
|
||||
await signIn();
|
||||
res = await call("/api/auth/oauth/[email protected]&add=1");
|
||||
assert.equal(res.status, 302);
|
||||
const signInPage = new URL(res.headers.get("location")!);
|
||||
assert.equal(signInPage.searchParams.get("prompt"), "login", "the server's page must not reuse the first sign-in");
|
||||
|
||||
// The mock's page signs the same account in again: it stays one account
|
||||
const approved = await fetch(signInPage, { redirect: "manual" });
|
||||
const back = new URL(approved.headers.get("location")!);
|
||||
res = await call(`/api/auth/callback${back.search}`);
|
||||
assert.equal(res.headers.get("location"), "/");
|
||||
const list = await jsonOf(await call("/api/auth/accounts"));
|
||||
assert.deepEqual(list.accounts.map((a: { username: string }) => a.username), ["[email protected]"]);
|
||||
});
|
||||
+9
-4
@@ -97,6 +97,8 @@ interface Pending {
|
||||
base: string;
|
||||
username: string;
|
||||
remember: boolean;
|
||||
/** MA-B: signing in a second account beside the one in front. */
|
||||
adding: boolean;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
@@ -114,13 +116,13 @@ function challengeOf(verifier: string): string {
|
||||
* Begin a sign-in. Returns where to send the browser, and the state to bind
|
||||
* to it in a cookie.
|
||||
*/
|
||||
export async function start(params: { username: string; base: string; remember: boolean }): Promise<{ location: string; state: string }> {
|
||||
export async function start(params: { username: string; base: string; remember: boolean; adding?: boolean }): Promise<{ location: string; state: string }> {
|
||||
const metadata = await metadataFor(params.base);
|
||||
sweepPending();
|
||||
if (pending.size >= MAX_PENDING) throw new UpstreamError("Too many sign-ins in progress", 503);
|
||||
const state = randomToken(24);
|
||||
const verifier = randomToken(48);
|
||||
pending.set(state, { verifier, base: params.base, username: params.username, remember: params.remember, createdAt: Date.now() });
|
||||
pending.set(state, { verifier, base: params.base, username: params.username, remember: params.remember, adding: Boolean(params.adding), createdAt: Date.now() });
|
||||
const scope = ["openid", "offline_access"].filter((s) => metadata.scopes.length === 0 || metadata.scopes.includes(s)).join(" ");
|
||||
const url = new URL(metadata.authorizationEndpoint);
|
||||
url.searchParams.set("response_type", "code");
|
||||
@@ -131,6 +133,9 @@ export async function start(params: { username: string; base: string; remember:
|
||||
url.searchParams.set("code_challenge", challengeOf(verifier));
|
||||
url.searchParams.set("code_challenge_method", "S256");
|
||||
if (params.username) url.searchParams.set("login_hint", params.username);
|
||||
// MA-B: ask again, rather than let the server's page reuse the sign-in of
|
||||
// the account already in front
|
||||
if (params.adding) url.searchParams.set("prompt", "login");
|
||||
return { location: url.toString(), state };
|
||||
}
|
||||
|
||||
@@ -177,7 +182,7 @@ export class SignInError extends Error {
|
||||
* Finish a sign-in: `state` as it came back in the URL, `boundState` as the
|
||||
* browser's cookie holds it. Each state is good for one attempt.
|
||||
*/
|
||||
export async function finish(params: { state: string; boundState: string | undefined; code: string }): Promise<{ tokens: TokenSet; base: string; username: string; remember: boolean }> {
|
||||
export async function finish(params: { state: string; boundState: string | undefined; code: string }): Promise<{ tokens: TokenSet; base: string; username: string; remember: boolean; adding: boolean }> {
|
||||
const p = pending.get(params.state);
|
||||
if (!p || !params.boundState || params.boundState !== params.state) {
|
||||
throw new SignInError("state_mismatch", "This sign-in didn't start in this browser. Try again.");
|
||||
@@ -192,7 +197,7 @@ export async function finish(params: { state: string; boundState: string | undef
|
||||
redirect_uri: redirectUri(),
|
||||
});
|
||||
if (!tokens) throw new SignInError("exchange_failed", "The mail server didn't accept the sign-in. Try again.");
|
||||
return { tokens, base: p.base, username: p.username, remember: p.remember };
|
||||
return { tokens, base: p.base, username: p.username, remember: p.remember, adding: p.adding };
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { useSession } from "@/store/session";
|
||||
|
||||
/**
|
||||
* inbuxa MA-B: the accounts signed in in this browser. The server lists them
|
||||
* and swaps the one in front; the web app clears what it cached for the
|
||||
* previous account and reloads, and signing out of one reloads into the next.
|
||||
*/
|
||||
|
||||
let reload: ReturnType<typeof vi.fn>;
|
||||
let calls: { url: string; method: string }[];
|
||||
let answers: Record<string, unknown>;
|
||||
|
||||
beforeEach(() => {
|
||||
reload = vi.fn();
|
||||
Object.defineProperty(window, "location", { configurable: true, value: { ...window.location, reload } });
|
||||
calls = [];
|
||||
answers = {};
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (url: string, init?: RequestInit) => {
|
||||
const path = String(url);
|
||||
calls.push({ url: path, method: init?.method ?? "GET" });
|
||||
const key = Object.keys(answers).find((k) => path.endsWith(k));
|
||||
const body = key ? answers[key] : { ok: true };
|
||||
return { ok: true, status: 200, json: async () => body, text: async () => JSON.stringify(body) } as Response;
|
||||
}),
|
||||
);
|
||||
localStorage.setItem("ihasmail:cached-thing", "from the account in front");
|
||||
useSession.setState({ status: "authenticated", signedIn: [], canAddAccount: false });
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
describe("account switcher", () => {
|
||||
it("lists the accounts and whether one more may be added", async () => {
|
||||
answers["/api/auth/accounts"] = {
|
||||
accounts: [
|
||||
{ id: "a", username: "[email protected]", front: true },
|
||||
{ id: "b", username: "[email protected]", front: false },
|
||||
],
|
||||
canAdd: true,
|
||||
};
|
||||
await useSession.getState().loadSignedIn();
|
||||
expect(useSession.getState().signedIn.map((a) => a.username)).toEqual(["[email protected]", "[email protected]"]);
|
||||
expect(useSession.getState().canAddAccount).toBe(true);
|
||||
});
|
||||
|
||||
it("switching asks the server, forgets the cache and reloads", async () => {
|
||||
await useSession.getState().switchTo("b");
|
||||
expect(calls.some((c) => c.url.endsWith("/api/auth/accounts/b/front") && c.method === "POST")).toBe(true);
|
||||
expect(localStorage.getItem("ihasmail:cached-thing")).toBeNull();
|
||||
expect(reload).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("signing out of one reloads into the next when there is one", async () => {
|
||||
answers["/api/auth/logout"] = { ok: true, next: true };
|
||||
await useSession.getState().logout();
|
||||
expect(reload).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("signing out of the last one, or of all, ends at the sign-in page", async () => {
|
||||
answers["/api/auth/logout-all"] = { ok: true };
|
||||
await useSession.getState().logoutAll();
|
||||
expect(calls.some((c) => c.url.endsWith("/api/auth/logout-all"))).toBe(true);
|
||||
expect(reload).not.toHaveBeenCalled();
|
||||
expect(useSession.getState().status).toBe("anonymous");
|
||||
// The next ordinary sign-out goes back to signing out of one
|
||||
useSession.setState({ status: "authenticated" });
|
||||
answers["/api/auth/logout"] = { ok: true };
|
||||
await useSession.getState().logout();
|
||||
expect(calls.filter((c) => c.url.endsWith("/api/auth/logout")).length).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -28,6 +28,10 @@ interface SessionState {
|
||||
viewing: Id | null;
|
||||
/** Shared and group mailboxes the reader can open (MA-A); see lib/sharedMail. */
|
||||
sharedMail: SharedMailAccount[];
|
||||
/** inbuxa MA-B: the accounts signed in in this browser, the one in front first. */
|
||||
signedIn: SignedInAccount[];
|
||||
/** Whether one more may be added (the cap, and both organizations' addAccounts). */
|
||||
canAddAccount: boolean;
|
||||
/** The name of an account the reader lost while it was in view. */
|
||||
delegationEnded: string | null;
|
||||
error: string | null;
|
||||
@@ -36,7 +40,14 @@ interface SessionState {
|
||||
pushState: PushState;
|
||||
bootstrap(): Promise<void>;
|
||||
login(username: string, password: string, totp: string, remember: boolean): Promise<void>;
|
||||
/** Signs out of the account in front; another signed-in one comes forward. */
|
||||
logout(): Promise<void>;
|
||||
/** inbuxa MA-B: ends every account signed in in this browser. */
|
||||
logoutAll(): Promise<void>;
|
||||
/** inbuxa MA-B: finds the other accounts signed in here. */
|
||||
loadSignedIn(): Promise<void>;
|
||||
/** inbuxa MA-B: brings another signed-in account to the front, and reloads. */
|
||||
switchTo(sessionId: string): Promise<void>;
|
||||
refresh(): Promise<void>;
|
||||
setAccount(id: Id): void;
|
||||
/** Show a delegated account's or shared mailbox's mail, or the reader's own with null. */
|
||||
@@ -59,12 +70,24 @@ interface SessionState {
|
||||
|
||||
let refreshing: Promise<void> | null = null;
|
||||
|
||||
/** What a signed-in account looks like in the switcher (MA-B). */
|
||||
export interface SignedInAccount {
|
||||
id: string;
|
||||
username: string;
|
||||
front: boolean;
|
||||
}
|
||||
|
||||
/** Which sign-out: the account in front, or every one (MA-B). */
|
||||
let signOutPath = "/api/auth/logout";
|
||||
|
||||
export const useSession = create<SessionState>((set, get) => ({
|
||||
status: "loading",
|
||||
session: null,
|
||||
accountId: null,
|
||||
viewing: null,
|
||||
sharedMail: [],
|
||||
signedIn: [],
|
||||
canAddAccount: false,
|
||||
delegationEnded: null,
|
||||
error: null,
|
||||
pushConnected: false,
|
||||
@@ -116,8 +139,11 @@ export const useSession = create<SessionState>((set, get) => ({
|
||||
} catch {
|
||||
/* never block signing out over this */
|
||||
}
|
||||
const path = signOutPath;
|
||||
signOutPath = "/api/auth/logout";
|
||||
let next = false;
|
||||
try {
|
||||
await apiFetch("/api/auth/logout", { method: "POST" });
|
||||
next = Boolean((await apiFetch<{ next?: boolean }>(path, { method: "POST" }))?.next);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
@@ -127,7 +153,34 @@ export const useSession = create<SessionState>((set, get) => ({
|
||||
// problem next -- and the address book cached here is the same argument.
|
||||
clearSignedInData();
|
||||
client.session = null;
|
||||
set({ status: "anonymous", session: null, accountId: null, viewing: null, sharedMail: [] });
|
||||
// inbuxa MA-B: another signed-in account is in front now
|
||||
if (next) {
|
||||
window.location.reload();
|
||||
return;
|
||||
}
|
||||
set({ status: "anonymous", session: null, accountId: null, viewing: null, sharedMail: [], signedIn: [], canAddAccount: false });
|
||||
},
|
||||
|
||||
async logoutAll() {
|
||||
// The same care as signing out of one, then every account ends
|
||||
signOutPath = "/api/auth/logout-all";
|
||||
await get().logout();
|
||||
},
|
||||
|
||||
async loadSignedIn() {
|
||||
try {
|
||||
const answer = await apiFetch<{ accounts: SignedInAccount[]; canAdd: boolean }>("/api/auth/accounts");
|
||||
set({ signedIn: answer.accounts, canAddAccount: answer.canAdd });
|
||||
} catch {
|
||||
set({ signedIn: [], canAddAccount: false });
|
||||
}
|
||||
},
|
||||
|
||||
async switchTo(sessionId) {
|
||||
await apiFetch(`/api/auth/accounts/${encodeURIComponent(sessionId)}/front`, { method: "POST" });
|
||||
// What was cached belongs to the account that was in front
|
||||
clearSignedInData();
|
||||
window.location.reload();
|
||||
},
|
||||
|
||||
refresh() {
|
||||
@@ -213,6 +266,7 @@ function applySession(s: JmapSession, set: (p: Partial<SessionState>) => void) {
|
||||
const accountId = s.primaryAccounts[CAP.mail] ?? Object.keys(s.accounts)[0] ?? null;
|
||||
set({ status: "authenticated", session: s, accountId, viewing: null, sharedMail: [], error: null });
|
||||
void useSession.getState().loadSharedMail();
|
||||
void useSession.getState().loadSignedIn();
|
||||
}
|
||||
|
||||
client.onUnauthenticated(() => {
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { Dialog } from "@/ui/dialog";
|
||||
import { apiFetch } from "@/jmap/client";
|
||||
import { withBase } from "@/lib/basePath";
|
||||
import { clearSignedInData, isDeviceTrusted } from "@/lib/storage";
|
||||
import { t } from "@/lib/i18n";
|
||||
|
||||
/**
|
||||
* inbuxa MA-B: sign a second account in beside the one in front.
|
||||
*
|
||||
* With sign-in on the mail server's own page and one mail server, there is
|
||||
* nothing to ask here: the browser goes straight to that page, which asks for
|
||||
* the account. With several servers the address comes first, to pick one; with
|
||||
* the password form, so does the password. Either way the account joins the
|
||||
* others and comes to the front, and the app reloads into it.
|
||||
*
|
||||
* It is remembered on this device exactly as the first one was (MA-7).
|
||||
*/
|
||||
export function AddAccountDialog({ open, onClose }: { open: boolean; onClose: () => void }) {
|
||||
const [mode, setMode] = useState<"oauth" | "oauth-address" | "password" | null>(null);
|
||||
const [username, setUsername] = useState("");
|
||||
const [password, setPassword] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const remember = isDeviceTrusted();
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setError(null);
|
||||
let live = true;
|
||||
fetch(withBase("/api/config"))
|
||||
.then((r) => (r.ok ? r.json() : null))
|
||||
.then((c) => {
|
||||
if (!live) return;
|
||||
const oauth = c?.signIn === "oauth";
|
||||
const next = oauth ? (c?.signInDirect === true ? "oauth" : "oauth-address") : "password";
|
||||
setMode(next);
|
||||
// Nothing to ask: off to the mail server's page
|
||||
if (next === "oauth") goToServer("");
|
||||
})
|
||||
.catch(() => live && setMode("password"));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
// goToServer only reads `remember`, fixed for the dialog's life
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open]);
|
||||
|
||||
function goToServer(address: string) {
|
||||
setBusy(true);
|
||||
// What is cached belongs to the account in front, which won't be
|
||||
clearSignedInData();
|
||||
const params = new URLSearchParams({ add: "1", ...(address ? { username: address } : {}), ...(remember ? { remember: "1" } : {}) });
|
||||
window.location.assign(withBase(`/api/auth/oauth/start?${params}`));
|
||||
}
|
||||
|
||||
const submit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!username.trim()) return;
|
||||
if (mode === "oauth-address") return goToServer(username.trim());
|
||||
if (!password) return;
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiFetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ username: username.trim(), password, remember, add: true }),
|
||||
});
|
||||
clearSignedInData();
|
||||
window.location.reload();
|
||||
} catch (err) {
|
||||
setError((err as Error).message || t("That account couldn't be added."));
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (mode === "oauth") return null;
|
||||
return (
|
||||
<Dialog
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
title={t("Add an account")}
|
||||
size="sm"
|
||||
footer={
|
||||
<>
|
||||
<button className="btn btn-ghost" onClick={onClose} disabled={busy}>
|
||||
{t("Cancel")}
|
||||
</button>
|
||||
<button className="btn btn-primary" form="add-account" type="submit" disabled={busy || !username.trim() || (mode === "password" && !password)}>
|
||||
{busy ? t("Working…") : t("Add account")}
|
||||
</button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<form id="add-account" onSubmit={(e) => void submit(e)}>
|
||||
<p className="hint">{t("Both accounts stay signed in here; switch between them from this menu.")}</p>
|
||||
<div className="field">
|
||||
<label htmlFor="add-account-user">{t("Email address")}</label>
|
||||
<input id="add-account-user" autoComplete="username" value={username} onChange={(e) => setUsername(e.target.value)} autoFocus />
|
||||
</div>
|
||||
{mode === "password" && (
|
||||
<div className="field">
|
||||
<label htmlFor="add-account-pw">{t("Password")}</label>
|
||||
<input id="add-account-pw" type="password" autoComplete="current-password" value={password} onChange={(e) => setPassword(e.target.value)} />
|
||||
</div>
|
||||
)}
|
||||
{error && <p className="error">{error}</p>}
|
||||
</form>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import { composeBlocked, draftFromMailto, useCompose } from "@/store/compose";
|
||||
import { delegatedAccounts } from "@/lib/delegation";
|
||||
import { toast } from "@/ui/toast";
|
||||
import { DelegatedBar } from "./DelegatedBar";
|
||||
import { AddAccountDialog } from "./AddAccountDialog";
|
||||
import { Avatar, useIsMobile } from "@/ui/misc";
|
||||
import { MenuItem, MenuSep, Popover, useMenu } from "@/ui/popover";
|
||||
import { Splitter } from "@/ui/Splitter";
|
||||
@@ -73,6 +74,35 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
const pushState = useSession((s) => s.pushState);
|
||||
const session = useSession((s) => s.session);
|
||||
const logout = useSession((s) => s.logout);
|
||||
// inbuxa MA-B: the other accounts signed in here, and adding one
|
||||
const signedIn = useSession((s) => s.signedIn);
|
||||
const canAddAccount = useSession((s) => s.canAddAccount);
|
||||
const [addingAccount, setAddingAccount] = useState(false);
|
||||
const bringForward = (sessionId: string) => {
|
||||
acctMenu.close();
|
||||
// A message being written belongs to the account it was started in
|
||||
if (useCompose.getState().drafts.length) {
|
||||
toast.show(t("Send or close the message you're writing first."));
|
||||
return;
|
||||
}
|
||||
void useSession.getState().switchTo(sessionId);
|
||||
};
|
||||
// A refused add comes back on the address (see the server's /auth/callback)
|
||||
useEffect(() => {
|
||||
const params = new URLSearchParams(window.location.search);
|
||||
const why = params.get("account_error");
|
||||
if (!why) return;
|
||||
toast.show(
|
||||
why === "add_full"
|
||||
? t("You can't add more accounts here.")
|
||||
: why === "add_other_server"
|
||||
? t("That account is on another mail server. Only accounts on this server can be added.")
|
||||
: t("Your organization doesn't allow adding other accounts here."),
|
||||
);
|
||||
params.delete("account_error");
|
||||
const rest = params.toString();
|
||||
window.history.replaceState(null, "", window.location.pathname + (rest ? `?${rest}` : ""));
|
||||
}, []);
|
||||
const appName = useSession((s) => s.session?.ihasmail?.appName) || DEFAULT_APP_NAME;
|
||||
const acctMenu = useMenu();
|
||||
const administers = hasAdministration(usePermissions());
|
||||
@@ -220,6 +250,33 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
</div>
|
||||
</div>
|
||||
<MenuSep />
|
||||
{/* inbuxa MA-B: each account signed in here, the one in front ticked */}
|
||||
{(signedIn.length > 1 || canAddAccount) && (
|
||||
<>
|
||||
{signedIn.length > 1 && <div className="hint" style={{ padding: "4px 10px" }}>{t("Accounts")}</div>}
|
||||
{signedIn.length > 1 &&
|
||||
signedIn.map((account) => (
|
||||
<MenuItem
|
||||
key={account.id}
|
||||
icon={account.front ? <Check size={16} /> : <Mail size={16} />}
|
||||
label={<span className="notranslate" translate="no">{account.username}</span>}
|
||||
active={account.front}
|
||||
onClick={() => (account.front ? acctMenu.close() : bringForward(account.id))}
|
||||
/>
|
||||
))}
|
||||
{canAddAccount && (
|
||||
<MenuItem
|
||||
icon={<Plus size={16} />}
|
||||
label={t("Add account")}
|
||||
onClick={() => {
|
||||
acctMenu.close();
|
||||
setAddingAccount(true);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<MenuSep />
|
||||
</>
|
||||
)}
|
||||
{delegated.length + sharedMail.length > 0 && (
|
||||
<>
|
||||
<div className="hint" style={{ padding: "4px 10px" }}>{t("Mail to show")}</div>
|
||||
@@ -277,7 +334,11 @@ export function AppShell({ children }: { children: ReactNode }) {
|
||||
)}
|
||||
<MenuItem icon={<RefreshCw size={16} />} label={t("Refresh")} onClick={() => window.location.reload()} />
|
||||
<MenuItem icon={<LogOut size={16} />} label={t("Sign out")} onClick={() => void logout()} />
|
||||
{signedIn.length > 1 && (
|
||||
<MenuItem icon={<LogOut size={16} />} label={t("Sign out of all accounts")} onClick={() => void useSession.getState().logoutAll()} />
|
||||
)}
|
||||
</Popover>
|
||||
<AddAccountDialog open={addingAccount} onClose={() => setAddingAccount(false)} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
|
||||
Reference in new issue
Block a user