diff --git a/server/src/accounts.test.ts b/server/src/accounts.test.ts new file mode 100644 index 0000000..431dd5e --- /dev/null +++ b/server/src/accounts.test.ts @@ -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 `_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 = "first@example.com"; +process.env.MOCK_PASS = "first-password"; +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"; + +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(); + + 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("first@example.com", "first-password")).status, 200); + assert.deepEqual((await b.accounts()).map((a) => a.username), ["first@example.com"]); + const first = b.jar.get(FRONT); + + const added = await b.signIn("second@example.com", "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]), [["second@example.com", true], ["first@example.com", false]]); + + // The same account again is not a second copy + await b.signIn("first@example.com", "first-password", true); + accounts = await b.accounts(); + assert.equal(accounts.length, 2); + assert.equal(accounts[0]!.username, "first@example.com", "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, "second@example.com"); + + // 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), ["first@example.com"]); + + // Sign out of all + await b.signIn("second@example.com", "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("first@example.com", "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("first@example.com", "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("second@example.com", "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), ["first@example.com"], "the front stayed"); + } finally { + delete process.env.MOCK_NO_ADD_ACCOUNTS; + } +}); diff --git a/server/src/accounts.ts b/server/src/accounts.ts new file mode 100644 index 0000000..820a8ae --- /dev/null +++ b/server/src/accounts.ts @@ -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, `_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(); + 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 } | 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 = { + 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.", +}; diff --git a/server/src/app.ts b/server/src/app.ts index c350bab..86fec4b 100644 --- a/server/src/app.ts +++ b/server/src/app.ts @@ -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 { + 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>, +): Promise { + 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 { 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 { 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 { 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 { 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 { 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 }); }); diff --git a/server/src/mock/index.ts b/server/src/mock/index.ts index 491e2de..748ae31 100644 --- a/server/src/mock/index.ts +++ b/server/src/mock/index.ts @@ -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 & { 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 }; + 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") { diff --git a/server/src/oauth.test.ts b/server/src/oauth.test.ts index 236111c..e15528b 100644 --- a/server/src/oauth.test.ts +++ b/server/src/oauth.test.ts @@ -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/start?username=demo@example.com&add=1"); + assert.equal(new URL(res.headers.get("location")!).searchParams.has("prompt"), false); + + await signIn(); + res = await call("/api/auth/oauth/start?username=demo@example.com&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), ["demo@example.com"]); +}); diff --git a/server/src/oauth.ts b/server/src/oauth.ts index e9c81d0..9cd7598 100644 --- a/server/src/oauth.ts +++ b/server/src/oauth.ts @@ -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 }; } /** diff --git a/web/src/store/__tests__/account-switcher.test.ts b/web/src/store/__tests__/account-switcher.test.ts new file mode 100644 index 0000000..b5022f5 --- /dev/null +++ b/web/src/store/__tests__/account-switcher.test.ts @@ -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; +let calls: { url: string; method: string }[]; +let answers: Record; + +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: "first@example.com", front: true }, + { id: "b", username: "second@example.com", front: false }, + ], + canAdd: true, + }; + await useSession.getState().loadSignedIn(); + expect(useSession.getState().signedIn.map((a) => a.username)).toEqual(["first@example.com", "second@example.com"]); + 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); + }); +}); diff --git a/web/src/store/session.ts b/web/src/store/session.ts index 1a9582f..9f46b14 100644 --- a/web/src/store/session.ts +++ b/web/src/store/session.ts @@ -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; login(username: string, password: string, totp: string, remember: boolean): Promise; + /** Signs out of the account in front; another signed-in one comes forward. */ logout(): Promise; + /** inbuxa MA-B: ends every account signed in in this browser. */ + logoutAll(): Promise; + /** inbuxa MA-B: finds the other accounts signed in here. */ + loadSignedIn(): Promise; + /** inbuxa MA-B: brings another signed-in account to the front, and reloads. */ + switchTo(sessionId: string): Promise; refresh(): Promise; 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 | 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((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((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((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) => 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(() => { diff --git a/web/src/views/AddAccountDialog.tsx b/web/src/views/AddAccountDialog.tsx new file mode 100644 index 0000000..2f76250 --- /dev/null +++ b/web/src/views/AddAccountDialog.tsx @@ -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(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 ( + + + + + } + > +
void submit(e)}> +

{t("Both accounts stay signed in here; switch between them from this menu.")}

+
+ + setUsername(e.target.value)} autoFocus /> +
+ {mode === "password" && ( +
+ + setPassword(e.target.value)} /> +
+ )} + {error &&

{error}

} +
+
+ ); +} diff --git a/web/src/views/AppShell.tsx b/web/src/views/AppShell.tsx index a0a28a5..b032e04 100644 --- a/web/src/views/AppShell.tsx +++ b/web/src/views/AppShell.tsx @@ -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 }) { + {/* inbuxa MA-B: each account signed in here, the one in front ticked */} + {(signedIn.length > 1 || canAddAccount) && ( + <> + {signedIn.length > 1 &&
{t("Accounts")}
} + {signedIn.length > 1 && + signedIn.map((account) => ( + : } + label={{account.username}} + active={account.front} + onClick={() => (account.front ? acctMenu.close() : bringForward(account.id))} + /> + ))} + {canAddAccount && ( + } + label={t("Add account")} + onClick={() => { + acctMenu.close(); + setAddingAccount(true); + }} + /> + )} + + + )} {delegated.length + sharedMail.length > 0 && ( <>
{t("Mail to show")}
@@ -277,7 +334,11 @@ export function AppShell({ children }: { children: ReactNode }) { )} } label={t("Refresh")} onClick={() => window.location.reload()} /> } label={t("Sign out")} onClick={() => void logout()} /> + {signedIn.length > 1 && ( + } label={t("Sign out of all accounts")} onClick={() => void useSession.getState().logoutAll()} /> + )} + setAddingAccount(false)} />