From a30f989c578eee8015d9340d773bd367ae991cd8 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 06:32:27 +0100 Subject: [PATCH 1/7] feat: add a desktop build of the webmail that no server build can switch on --- .../settings/account-security-settings.tsx | 11 +- lib/__tests__/standalone-guard.test.ts | 27 ++++ lib/account-utils.ts | 5 + lib/admin/jmap-servers.ts | 6 + lib/desktop/__tests__/trust-flag.test.ts | 112 +++++++++++++++++ lib/desktop/blocked-routes.ts | 10 ++ lib/desktop/bridge.ts | 20 +++ lib/desktop/flag.ts | 17 +++ lib/desktop/trusted-servers.ts | 33 +++++ lib/desktop/types.ts | 117 ++++++++++++++++++ lib/theme-color-meta.ts | 12 ++ lib/web-push.ts | 3 + next.config.ts | 4 + package.json | 1 + proxy.ts | 6 + scripts/build-desktop.mjs | 22 ++++ scripts/desktop-build-flag.mjs | 12 ++ scripts/standalone-guard.mjs | 27 +++- 18 files changed, 440 insertions(+), 5 deletions(-) create mode 100644 lib/desktop/__tests__/trust-flag.test.ts create mode 100644 lib/desktop/blocked-routes.ts create mode 100644 lib/desktop/bridge.ts create mode 100644 lib/desktop/flag.ts create mode 100644 lib/desktop/trusted-servers.ts create mode 100644 lib/desktop/types.ts create mode 100644 scripts/build-desktop.mjs create mode 100644 scripts/desktop-build-flag.mjs diff --git a/components/settings/account-security-settings.tsx b/components/settings/account-security-settings.tsx index acee9fd75..184c979e3 100644 --- a/components/settings/account-security-settings.tsx +++ b/components/settings/account-security-settings.tsx @@ -1,6 +1,7 @@ 'use client'; import { useState, useEffect, useMemo, useCallback, useId, useRef } from 'react'; +import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag'; import { useParams } from 'next/navigation'; import { useTranslations } from 'next-intl'; import QRCode from 'qrcode'; @@ -1569,8 +1570,12 @@ export function AccountSecuritySettings() { {!IS_LITE ? (
- -
+ {!IS_DESKTOP_BUILD && ( + <> + +
+ + )}
) : ( @@ -1610,7 +1615,7 @@ export function AccountSecuritySettings() { description={withoutPassword ? t('description') : t('sections.devices_description')} >
- {!IS_LITE && ( + {!IS_LITE && !IS_DESKTOP_BUILD && ( <>
diff --git a/lib/__tests__/standalone-guard.test.ts b/lib/__tests__/standalone-guard.test.ts index dbc69e792..f982ecb14 100644 --- a/lib/__tests__/standalone-guard.test.ts +++ b/lib/__tests__/standalone-guard.test.ts @@ -31,6 +31,33 @@ afterEach(() => { while (temps.length) rmSync(temps.pop()!, { recursive: true, force: true }); }); +describe('the desktop trust path', () => { + it('fails a server build that still holds it', () => { + const dir = tempDir(); + put(dir, 'server.js'); + put(dir, '.next/server/chunks/123.js', 'holder[Symbol.for("bulwark.desktop.trustedJmapOrigins")]'); + expect(guardStandalone(dir, { desktopBuild: false })).toEqual(['.next/server/chunks/123.js (desktop trust path in a server build)']); + }); + + it('passes the desktop build itself', () => { + const dir = tempDir(); + put(dir, 'server.js'); + put(dir, '.next/server/chunks/123.js', 'holder[Symbol.for("bulwark.desktop.trustedJmapOrigins")]'); + expect(guardStandalone(dir, { desktopBuild: true })).toEqual([]); + }); + + it('fails the command line check on a server build', () => { + const dir = tempDir(); + put(dir, 'server.js'); + put(dir, '.next/server/chunks/123.js', 'bulwark.desktop.trustedJmapOrigins'); + const env = { ...process.env }; + delete env.NEXT_PUBLIC_BULWARK_DESKTOP; + const run = spawnSync(process.execPath, [SCRIPT, dir], { encoding: 'utf8', env }); + expect(run.status).toBe(1); + expect(run.stderr).toContain('desktop trust path'); + }); +}); + describe('standalone guard check', () => { it('fails the build on a planted signing key', () => { const dir = tempDir(); diff --git a/lib/account-utils.ts b/lib/account-utils.ts index c6cb10965..301333af0 100644 --- a/lib/account-utils.ts +++ b/lib/account-utils.ts @@ -1,3 +1,5 @@ +import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag'; + /** * Utilities for multi-account support: * - Account ID generation @@ -136,6 +138,9 @@ export function isHttp2Available(): boolean { * once HTTP/2+ is observed, otherwise returns {@link MAX_ACCOUNTS_HTTP1}. */ export function getMaxAccounts(): number { + // The cap is for the push streams sharing one http/1.1 origin. The embedded + // server speaks http/1.1, but each account streams from its own mail server. + if (IS_DESKTOP_BUILD) return MAX_ACCOUNT_SLOTS; return isHttp2Available() ? MAX_ACCOUNT_SLOTS : MAX_ACCOUNTS_HTTP1; } diff --git a/lib/admin/jmap-servers.ts b/lib/admin/jmap-servers.ts index 67319b54d..fa054875a 100644 --- a/lib/admin/jmap-servers.ts +++ b/lib/admin/jmap-servers.ts @@ -1,6 +1,9 @@ /** * Multi-server JMAP support: schema, parsing, lookup, and redaction helpers. */ +import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag'; +import { isDesktopTrustedServer } from '@/lib/desktop/trusted-servers'; + export interface JmapServerOAuthConfig { clientId?: string; @@ -223,6 +226,9 @@ export function resolveTrustedJmapUrl( } const matched = servers.find((s) => normalizeUrl(s.url) === target); if (matched) return matched.url; + // A server the user approved in the desktop app. Compiled out of every other + // build, so nothing at runtime can open this path on a server. + if (IS_DESKTOP_BUILD && isDesktopTrustedServer(requestedUrl)) return trimUrl(requestedUrl); // No match - caller decides whether to honor the request anyway (e.g. when // allowCustomJmapEndpoint is enabled). return null; diff --git a/lib/desktop/__tests__/trust-flag.test.ts b/lib/desktop/__tests__/trust-flag.test.ts new file mode 100644 index 000000000..202a4b1ff --- /dev/null +++ b/lib/desktop/__tests__/trust-flag.test.ts @@ -0,0 +1,112 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { readFileSync } from 'node:fs'; +import path from 'node:path'; +import { build } from 'esbuild'; +import { desktopBuildFlag } from '../../../scripts/desktop-build-flag.mjs'; + +const ROOT = path.resolve(__dirname, '../../..'); +const TRUST_KEY = Symbol.for('bulwark.desktop.trustedJmapOrigins'); +const PRIVATE_SERVER = 'http://192.168.1.5:8080'; + +type Holder = Record | undefined>; + +function trust(...origins: string[]) { + (globalThis as unknown as Holder)[TRUST_KEY] = new Set(origins); +} + +async function loadResolver() { + vi.resetModules(); + return (await import('@/lib/admin/jmap-servers')).resolveTrustedJmapUrl; +} + +beforeEach(() => { + delete (globalThis as unknown as Holder)[TRUST_KEY]; +}); + +afterEach(() => { + vi.unstubAllEnvs(); + delete (globalThis as unknown as Holder)[TRUST_KEY]; +}); + +describe('the trusted set, server build', () => { + it('refuses a private URL even when the set holds it', async () => { + trust('http://192.168.1.5:8080'); + const resolve = await loadResolver(); + expect(resolve(PRIVATE_SERVER, '', [])).toBeNull(); + }); +}); + +describe('the trusted set, desktop build', () => { + beforeEach(() => { + vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1'); + }); + + it('accepts a server main approved', async () => { + trust('http://192.168.1.5:8080'); + const resolve = await loadResolver(); + expect(resolve(`${PRIVATE_SERVER}/`, '', [])).toBe(PRIVATE_SERVER); + }); + + it('does not trust everything: another private server stays out', async () => { + trust('http://192.168.1.5:8080'); + const resolve = await loadResolver(); + expect(resolve('http://192.168.1.6:8080', '', [])).toBeNull(); + expect(resolve('http://192.168.1.5:9999', '', [])).toBeNull(); + expect(resolve('http://169.254.169.254', '', [])).toBeNull(); + }); + + it('trusts nothing before main has sent a set', async () => { + const resolve = await loadResolver(); + expect(resolve(PRIVATE_SERVER, '', [])).toBeNull(); + }); + + it('ignores a URL with credentials in it', async () => { + trust('http://192.168.1.5:8080'); + const resolve = await loadResolver(); + expect(resolve('http://a:b@192.168.1.5:8080', '', [])).toBeNull(); + }); +}); + +describe('the flag is fixed at build time', () => { + /** Compiles lib/desktop/flag.ts the way next.config.ts `env` does and runs it. */ + async function flagOfBuild(buildEnv: NodeJS.ProcessEnv, runtimeEnv: NodeJS.ProcessEnv): Promise { + const result = await build({ + entryPoints: [path.join(ROOT, 'lib/desktop/flag.ts')], + bundle: true, + write: false, + format: 'cjs', + platform: 'node', + define: { 'process.env.NEXT_PUBLIC_BULWARK_DESKTOP': JSON.stringify(desktopBuildFlag(buildEnv)) }, + }); + const run = new Function('module', 'exports', 'process', result.outputFiles[0].text); + const module = { exports: {} as { IS_DESKTOP_BUILD?: boolean } }; + run(module, module.exports, { env: runtimeEnv }); + return module.exports.IS_DESKTOP_BUILD === true; + } + + it('a runtime env var cannot switch a server build on', async () => { + expect(await flagOfBuild({}, { NEXT_PUBLIC_BULWARK_DESKTOP: '1' })).toBe(false); + }); + + it('a desktop build is on without any runtime env var', async () => { + expect(await flagOfBuild({ NEXT_PUBLIC_BULWARK_DESKTOP: '1' }, {})).toBe(true); + }); + + it('next.config.ts always inlines the flag, so an unset one is no runtime read', () => { + const config = readFileSync(path.join(ROOT, 'next.config.ts'), 'utf8'); + expect(config).toMatch(/NEXT_PUBLIC_BULWARK_DESKTOP: desktopBuildFlag\(\)/); + expect(desktopBuildFlag({})).toBe(''); + }); + + it('flag.ts reads the variable in the one form the compiler replaces', () => { + const source = readFileSync(path.join(ROOT, 'lib/desktop/flag.ts'), 'utf8'); + expect(source).toContain("process.env.NEXT_PUBLIC_BULWARK_DESKTOP === '1'"); + }); + + it('no image build sets it', () => { + for (const file of ['Dockerfile', 'Dockerfile.lite']) { + expect(readFileSync(path.join(ROOT, file), 'utf8')).not.toContain('NEXT_PUBLIC_BULWARK_DESKTOP'); + } + }); +}); diff --git a/lib/desktop/blocked-routes.ts b/lib/desktop/blocked-routes.ts new file mode 100644 index 000000000..093b38cf0 --- /dev/null +++ b/lib/desktop/blocked-routes.ts @@ -0,0 +1,10 @@ +/** + * Routes with no meaning in the desktop build: pairing a phone and the mobile + * hand-off both hand out `https://app.bulwark.localhost`, which nothing else + * can reach, and the app has no use for impersonation. proxy.ts answers 404. + */ +const BLOCKED = /^\/api\/auth\/(pair|handoff|impersonate)(\/|$)/; + +export function isDesktopBlockedRoute(pathname: string): boolean { + return BLOCKED.test(pathname); +} diff --git a/lib/desktop/bridge.ts b/lib/desktop/bridge.ts new file mode 100644 index 000000000..852ebbfbe --- /dev/null +++ b/lib/desktop/bridge.ts @@ -0,0 +1,20 @@ +/** + * Finds the desktop app's bridge. Everything here is feature detected: in a + * browser there's no bridge, and a newer bridge version than this code knows + * is treated as absent rather than guessed at. + */ + +import { DESKTOP_BRIDGE_VERSION, type BulwarkDesktopBridge } from './types'; + +export function getDesktopBridge(): BulwarkDesktopBridge | null { + if (typeof window === 'undefined') return null; + const bridge = window.bulwarkDesktop; + if (!bridge || typeof bridge !== 'object') return null; + if (typeof bridge.version !== 'number' || bridge.version > DESKTOP_BRIDGE_VERSION) return null; + return bridge; +} + +/** True in any window of the desktop app, standalone or on an instance. */ +export function isDesktopApp(): boolean { + return getDesktopBridge() !== null; +} diff --git a/lib/desktop/flag.ts b/lib/desktop/flag.ts new file mode 100644 index 000000000..de72502e1 --- /dev/null +++ b/lib/desktop/flag.ts @@ -0,0 +1,17 @@ +/** + * The desktop app's own webmail build (`npm run build:desktop`). + * + * A build-time constant, inlined through next.config.ts `env` into client and + * server bundles alike, like `IS_LITE`. A server admin can't switch it on: in + * any other build the value is compiled in as empty, and the bundler drops + * the branches. Never read this flag through a runtime environment variable. + * + * What it unlocks, and only in that build: + * - login routes accept the servers the user approved in the app + * (`isDesktopTrustedServer`, the list comes from the Electron main process) + * - sign-in through the system browser for any server + * - a per-slot record of the upstream for refresh tokens + * - a login budget per mail server rather than per client address + * - pairing and hand-off routes are gone + */ +export const IS_DESKTOP_BUILD = process.env.NEXT_PUBLIC_BULWARK_DESKTOP === '1'; diff --git a/lib/desktop/trusted-servers.ts b/lib/desktop/trusted-servers.ts new file mode 100644 index 000000000..e2cf034b5 --- /dev/null +++ b/lib/desktop/trusted-servers.ts @@ -0,0 +1,33 @@ +/** + * The mail servers the user approved in the desktop app. + * + * The Electron main process owns this list and sends it to the embedded server + * at start and on every change (src/server/host.ts in the desktop repo sets it + * on globalThis, which all of Next's bundles share). Nothing in the renderer + * or the environment can write it, so script in a page can't aim the login + * routes at the LAN. + * + * Callers guard this with IS_DESKTOP_BUILD, so a server build compiles the + * whole thing away. scripts/standalone-guard.mjs checks that it did. + */ + +type TrustedOrigins = ReadonlySet; + +function originOf(url: string): string | null { + try { + const parsed = new URL(url); + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return null; + if (parsed.username || parsed.password) return null; + return parsed.origin; + } catch { + return null; + } +} + +export function isDesktopTrustedServer(url: string | null | undefined): boolean { + if (!url) return false; + const origin = originOf(url); + if (!origin) return false; + const holder = globalThis as unknown as Record; + return holder[Symbol.for('bulwark.desktop.trustedJmapOrigins')]?.has(origin) === true; +} diff --git a/lib/desktop/types.ts b/lib/desktop/types.ts new file mode 100644 index 000000000..7ae919aa6 --- /dev/null +++ b/lib/desktop/types.ts @@ -0,0 +1,117 @@ +/** + * The desktop app's preload bridge as the webmail sees it. Feature checks ask + * `window.bulwarkDesktop?.`; in a browser the object isn't there. + * + * The shell side lives in the desktop repo (src/shared/bridge.ts), a copy of + * this contract. Add a member here and there together. A breaking change bumps + * `version`; adding a member doesn't. + */ + +export const DESKTOP_BRIDGE_VERSION = 1; + +export type DesktopWindowType = 'main' | 'message' | 'composer' | 'settings'; + +export interface DesktopWindowInfo { + id: number; + type: DesktopWindowType; + title: string; + focused: boolean; +} + +export interface DesktopWindowsBridge { + /** + * Opens a pop-out window itself. The name is the window's identity: opening + * the same name again focuses the window that has it. Left out, the page uses + * window.open with a features string, which the shell's window-open handler + * sees too. + */ + open?: (url: string, options: { name: string; width: number; height: number }) => void; + /** One window per message: opening it again focuses it. */ + openMessage?: (target: { url: string; emailId: string }) => void; + openComposer?: (target: { url: string; name?: string }) => void; + /** Focuses a window from `list()`, or the main window with no id. */ + focus?: (id?: number) => void; + list?: () => Promise; + /** Fires when a window opens, closes, retitles or takes the focus. Returns the unsubscribe. */ + onChanged?: (listener: (windows: DesktopWindowInfo[]) => void) => () => void; +} + +export interface DesktopThemeBridge { + /** The page's colours, for title bar buttons and the first frame of new windows. */ + set: (theme: { dark: boolean; background: string; foreground?: string }) => void; + /** Fires when the OS switches between light and dark. Returns the unsubscribe. */ + onSystemChange?: (listener: (system: { dark: boolean; highContrast: boolean }) => void) => () => void; +} + +export interface DesktopMenuBridge { + /** + * What the app menu shows as enabled or checked, by command id. The shell + * keeps the last state per window and applies it when that window has focus. + */ + setState: (state: Record) => void; +} + +export interface DesktopShellBridge { + /** http, https and mailto only. */ + openExternal: (url: string) => void; +} + +export type DesktopServerPrepareResult = + | { ok: true; approved: boolean } + | { ok: false; reason: 'declined' | 'invalid' }; + +export interface DesktopServersBridge { + /** + * Call before signing in to a server. Asks the user first when it is on the + * LAN or plain http, and lets the app reach it. Standalone mode only. + */ + prepare: (serverUrl: string) => Promise; + /** The origins a session names, for the CORS headers the shell adds for them. */ + noteSession: ( + serverUrl: string, + urls: { apiUrl?: string; downloadUrl?: string; uploadUrl?: string; eventSourceUrl?: string }, + ) => void; +} + +export type DesktopBrowserSignInResult = + | { ok: true; code: string; state: string } + | { ok: false; error: string }; + +export interface DesktopAuthBridge { + /** Starts a short-lived loopback listener for one sign-in. */ + beginBrowserSignIn: () => Promise<{ flowId: string; redirectUri: string }>; + /** Opens the system browser on `authorizeUrl` and waits for the redirect. */ + completeBrowserSignIn: (flowId: string, authorizeUrl: string) => Promise; + cancelBrowserSignIn: (flowId: string) => void; +} + +export type DesktopPluginInstallResult = + | { ok: true; id: string; version: string } + | { ok: false; error: string; code?: string }; + +export interface DesktopPluginsBridge { + /** + * Installs from the directory through the local admin API, with its signature + * and hash checks. The renderer never holds an admin session. Standalone only. + */ + install: (slug: string, version: string) => Promise; +} + +export interface BulwarkDesktopBridge { + version: number; + platform: 'win32' | 'darwin' | 'linux'; + mode?: 'standalone' | 'instance'; + windows?: DesktopWindowsBridge; + theme?: DesktopThemeBridge; + menu?: DesktopMenuBridge; + shell?: DesktopShellBridge; + servers?: DesktopServersBridge; + auth?: DesktopAuthBridge; + plugins?: DesktopPluginsBridge; +} + +declare global { + interface Window { + bulwarkDesktop?: BulwarkDesktopBridge; + } +} diff --git a/lib/theme-color-meta.ts b/lib/theme-color-meta.ts index bcfd88881..dd9064e3f 100644 --- a/lib/theme-color-meta.ts +++ b/lib/theme-color-meta.ts @@ -1,3 +1,5 @@ +import { getDesktopBridge } from '@/lib/desktop/bridge'; + /** * Keeps in step with the active theme. * @@ -66,6 +68,7 @@ export function syncThemeColorMeta(): void { const run = () => { scheduled = false; writeMeta(); + tellDesktopShell(); }; if (typeof requestAnimationFrame === 'function') { @@ -75,6 +78,15 @@ export function syncThemeColorMeta(): void { } } +/** The desktop app's title bar buttons and new windows' first frame follow the page. */ +function tellDesktopShell(): void { + const theme = getDesktopBridge()?.theme; + if (!theme) return; + const background = document.querySelector('meta[name="theme-color"]')?.content; + if (!background) return; + theme.set({ dark: document.documentElement.classList.contains('dark'), background }); +} + /** Turn syncing on and take a first reading. Called by . */ export function enableThemeColorSync(): void { enabled = true; diff --git a/lib/web-push.ts b/lib/web-push.ts index f1739b441..4ab2e43de 100644 --- a/lib/web-push.ts +++ b/lib/web-push.ts @@ -5,6 +5,7 @@ // register endpoint we hit on the relay. import type { IJMAPClient } from '@/lib/jmap/client-interface'; +import { isDesktopApp } from '@/lib/desktop/bridge'; import type { AccountInfo, EmailPushConfig, Mailbox } from '@/lib/jmap/types'; import { isJmapSetError, jmapErrorType } from '@/lib/jmap/jmap-error'; import { DEFAULT_RELAY_BASE_URL } from '@/lib/push-relays'; @@ -443,6 +444,8 @@ export function isWebPushSupported(): boolean { // No service worker is registered in the static Lite build (see // components/service-worker-registration.tsx), so push cannot be enabled. if (IS_LITE) return false; + // Electron has the API, but subscribing always fails: it has no push service. + if (isDesktopApp()) return false; return ( 'serviceWorker' in navigator && 'PushManager' in window && diff --git a/next.config.ts b/next.config.ts index 1ce8a7714..b9bfbfb35 100644 --- a/next.config.ts +++ b/next.config.ts @@ -5,6 +5,7 @@ import { createHash } from "crypto"; import { readFileSync } from "fs"; import { join } from "path"; import { pruneTraces } from "./scripts/standalone-guard.mjs"; +import { desktopBuildFlag } from "./scripts/desktop-build-flag.mjs"; // Prefer an explicit build arg (passed in by CI / Docker, where .git is // excluded from the build context) and fall back to `git rev-parse` for @@ -193,6 +194,9 @@ const nextConfig: NextConfig = { NEXT_PUBLIC_BASE_PATH: basePath, NEXT_PUBLIC_DEV_MOCK_JMAP: process.env.DEV_MOCK_JMAP ?? "", NEXT_PUBLIC_BULWARK_LITE: isLite ? "1" : "", + // Always a literal, so setting the variable when starting a server build + // does nothing (lib/desktop/flag.ts). + NEXT_PUBLIC_BULWARK_DESKTOP: desktopBuildFlag(), }, }; diff --git a/package.json b/package.json index 52adefb78..f9ca56924 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "dev": "node scripts/build-pst-worker.mjs && node scripts/build-store-worker.mjs && node scripts/build-plugin-sandbox.mjs --watch -- next dev --turbopack", "build": "node scripts/build-pst-worker.mjs && node scripts/build-store-worker.mjs && node scripts/build-plugin-sandbox.mjs && next build --turbopack && node scripts/standalone-guard.mjs", "build:lite": "node scripts/lite/build.mjs", + "build:desktop": "node scripts/build-desktop.mjs", "lite:serve": "node scripts/lite/serve.mjs out 4173", "test:lite-smoke": "playwright test -c scripts/lite/smoke/playwright.config.ts", "test:lite-smoke:stalwart": "playwright test -c scripts/lite/smoke/playwright.stalwart.config.ts", diff --git a/proxy.ts b/proxy.ts index 4e47be5e8..92c1719e7 100644 --- a/proxy.ts +++ b/proxy.ts @@ -16,6 +16,8 @@ import { getWopiEditorOrigins } from "./lib/wopi/discovery"; import { detectSetupState } from "./lib/setup/state"; import { isServedOverHttps } from "./lib/security/request-protocol"; import { bootGateResponse } from "./lib/shared-store/boot-gate"; +import { IS_DESKTOP_BUILD } from "./lib/desktop/flag"; +import { isDesktopBlockedRoute } from "./lib/desktop/blocked-routes"; const intlMiddleware = createIntlMiddleware(routing); @@ -268,6 +270,10 @@ export async function proxy(request: NextRequest) { return NextResponse.json({ error: "Cross-origin request rejected" }, { status: 403 }); } + if (IS_DESKTOP_BUILD && isDesktopBlockedRoute(pathname)) { + return NextResponse.json({ error: "Not found" }, { status: 404 }); + } + if (PROXY_SKIP_PATTERN.test(pathname) || isStaticAssetPath(pathname)) { // No page CSP here, but what comes back can still be a document: the // not-found shell for a missing /foo.html or an unknown /api route. It diff --git a/scripts/build-desktop.mjs b/scripts/build-desktop.mjs new file mode 100644 index 000000000..5240927ba --- /dev/null +++ b/scripts/build-desktop.mjs @@ -0,0 +1,22 @@ +#!/usr/bin/env node +// The webmail build the desktop app embeds: `npm run build:desktop`. It's the +// regular standalone build with NEXT_PUBLIC_BULWARK_DESKTOP compiled in, using +// webpack (a third of Turbopack's server size, and its hashed external links +// don't relocate). No other build sets the flag: repos/desktop's +// scripts/fetch-webmail.mjs refuses a tree without it. + +import { spawnSync } from 'node:child_process'; + +const env = { ...process.env, NEXT_PUBLIC_BULWARK_DESKTOP: '1', NEXT_TELEMETRY_DISABLED: '1' }; +const isWindows = process.platform === 'win32'; + +for (const [command, ...args] of [ + ['node', 'scripts/build-pst-worker.mjs'], + ['node', 'scripts/build-store-worker.mjs'], + ['node', 'scripts/build-plugin-sandbox.mjs'], + ['npx', 'next', 'build', '--webpack'], + ['node', 'scripts/standalone-guard.mjs'], +]) { + const run = spawnSync(command, args, { env, stdio: 'inherit', shell: isWindows }); + if (run.status !== 0) process.exit(run.status ?? 1); +} diff --git a/scripts/desktop-build-flag.mjs b/scripts/desktop-build-flag.mjs new file mode 100644 index 000000000..a6f52d122 --- /dev/null +++ b/scripts/desktop-build-flag.mjs @@ -0,0 +1,12 @@ +// The value next.config.ts inlines as NEXT_PUBLIC_BULWARK_DESKTOP. +// +// Always defined, so every build compiles `process.env.NEXT_PUBLIC_BULWARK_DESKTOP` +// to a literal. Left undefined, Next would leave the read in the server bundle, +// and setting the variable when starting a server build would turn the flag on. + +/** The marker scripts/standalone-guard.mjs looks for in a non-desktop build. */ +export const DESKTOP_TRUST_MARKER = 'bulwark.desktop.trustedJmapOrigins'; + +export function desktopBuildFlag(env = process.env) { + return env.NEXT_PUBLIC_BULWARK_DESKTOP === '1' ? '1' : ''; +} diff --git a/scripts/standalone-guard.mjs b/scripts/standalone-guard.mjs index 80aaff1de..d44165b9e 100644 --- a/scripts/standalone-guard.mjs +++ b/scripts/standalone-guard.mjs @@ -18,6 +18,7 @@ import { existsSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; +import { DESKTOP_TRUST_MARKER, desktopBuildFlag } from './desktop-build-flag.mjs'; /** Top-level project entries the running server needs. Traces drop the rest. */ const TRACE_ALLOWED = new Set(['node_modules', '.next', 'public', 'locales', 'package.json', 'VERSION']); @@ -141,10 +142,32 @@ export function removeCopiedEnvFiles(standaloneDir) { return removed; } +/** + * Server bundles that still hold the desktop app's trust path. A server build + * compiles it away (lib/desktop/flag.ts); finding it means the flag leaked into + * this build or stopped being inlined, and a visitor could reach private hosts. + */ +export function findDesktopTrustPath(standaloneDir) { + const found = []; + const visit = (dir) => { + for (const entry of readdirSync(dir, { withFileTypes: true })) { + const full = path.join(dir, entry.name); + if (entry.isDirectory()) { + if (entry.name !== 'node_modules') visit(full); + } else if (entry.name.endsWith('.js') && readFileSync(full, 'utf8').includes(DESKTOP_TRUST_MARKER)) { + found.push(`${toPosix(path.relative(standaloneDir, full))} (desktop trust path in a server build)`); + } + } + }; + visit(standaloneDir); + return found.sort(); +} + /** Cleans and checks a standalone tree. Returns what is still wrong with it. */ -export function guardStandalone(standaloneDir) { +export function guardStandalone(standaloneDir, { desktopBuild = desktopBuildFlag() === '1' } = {}) { removeCopiedEnvFiles(standaloneDir); - return findForbidden(standaloneDir); + const forbidden = findForbidden(standaloneDir); + return desktopBuild ? forbidden : [...forbidden, ...findDesktopTrustPath(standaloneDir)]; } if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { From e65f7fe31fd83741dc58a45fa656e7320365e642 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 06:32:28 +0100 Subject: [PATCH 2/7] feat: let the desktop app reach LAN and plain http mail servers the user approved --- app/api/auth/session/route.ts | 2 +- app/api/auth/stalwart-context/route.ts | 2 +- app/api/auth/verify/route.ts | 4 +- lib/auth/client-address.ts | 10 +- lib/desktop/__tests__/login-routes.test.ts | 147 +++++++++++++++++++++ lib/desktop/jmap-route.ts | 42 ++++++ lib/desktop/login-budget.ts | 17 +++ lib/jmap/client.ts | 23 +++- lib/offline/offline-mode.ts | 3 +- 9 files changed, 239 insertions(+), 11 deletions(-) create mode 100644 lib/desktop/__tests__/login-routes.test.ts create mode 100644 lib/desktop/jmap-route.ts create mode 100644 lib/desktop/login-budget.ts diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts index 1afa1ab96..32b044ac2 100644 --- a/app/api/auth/session/route.ts +++ b/app/api/auth/session/route.ts @@ -184,7 +184,7 @@ export async function POST(request: NextRequest) { // password. `trusted` only relaxes the public-address requirement. // Wrong credentials tried from here count against this server's address // upstream, so each client gets a failure budget (lib/auth/verify-budget.ts). - const clientIp = loginClientAddress(request); + const clientIp = loginClientAddress(request, upstreamUrl); const refund = await reserveLoginAttempt(clientIp); if (!refund) { return NextResponse.json( diff --git a/app/api/auth/stalwart-context/route.ts b/app/api/auth/stalwart-context/route.ts index c2afc77b6..516f5ac11 100644 --- a/app/api/auth/stalwart-context/route.ts +++ b/app/api/auth/stalwart-context/route.ts @@ -80,7 +80,7 @@ export async function POST(request: NextRequest) { // private network) - never the credential check. // Wrong credentials tried from here count against this server's address // upstream, so each client gets a failure budget (lib/auth/verify-budget.ts). - const clientIp = loginClientAddress(request); + const clientIp = loginClientAddress(request, upstreamUrl); const refund = await reserveLoginAttempt(clientIp); if (!refund) { return NextResponse.json( diff --git a/app/api/auth/verify/route.ts b/app/api/auth/verify/route.ts index 028d6432a..dd404e9a3 100644 --- a/app/api/auth/verify/route.ts +++ b/app/api/auth/verify/route.ts @@ -5,7 +5,7 @@ import { configManager } from '@/lib/admin/config-manager'; import { isPublicHttpUrl } from '@/lib/security/url-guard'; import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers'; import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; -import { getClientIP } from '@/lib/admin/session'; +import { loginClientAddress } from '@/lib/auth/client-address'; import { reserveVerifyProbe, type BudgetRefund } from '@/lib/auth/verify-budget'; import { isStoreUnavailable } from '@/lib/auth/store-unavailable'; @@ -83,7 +83,7 @@ export async function POST(request: NextRequest) { // Wrong passwords tried from here count against this server's address // upstream; over budget the browser probes the JMAP server itself. - const clientIp = getClientIP(request); + const clientIp = loginClientAddress(request, upstreamUrl); let refund: BudgetRefund | null; try { refund = await reserveVerifyProbe(clientIp); diff --git a/lib/auth/client-address.ts b/lib/auth/client-address.ts index 6de013819..c53c42635 100644 --- a/lib/auth/client-address.ts +++ b/lib/auth/client-address.ts @@ -1,5 +1,7 @@ import { BlockList, isIP } from 'node:net'; import { getClientIP } from '@/lib/admin/session'; +import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag'; +import { desktopLoginBudgetKey } from '@/lib/desktop/login-budget'; /** Addresses that name no client: loopback and unspecified. */ const NOT_A_CLIENT = new BlockList(); @@ -39,7 +41,11 @@ export function forwardedClientHeaders(clientIp: string | undefined): Record ({ + NextResponse: { + json: (data: unknown, init?: { status?: number }) => ({ + json: async () => data, + status: init?.status ?? 200, + }), + }, +})); + +vi.mock('@/lib/logger', () => ({ + logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() }, +})); + +vi.mock('next/headers', () => ({ + cookies: async () => ({ get: () => undefined, set: vi.fn(), delete: vi.fn(), getAll: () => [] }), +})); + +const config: Record = { allowCustomJmapEndpoint: true }; +vi.mock('@/lib/admin/config-manager', () => ({ + configManager: { + ensureLoaded: async () => {}, + get: (key: string, fallback: unknown) => (key in config ? config[key] : fallback), + }, +})); + +vi.mock('@/lib/auth/crypto', () => ({ + encryptSession: () => 'encrypted-session', + decryptSession: () => null, + decryptPayload: () => null, + encryptPayload: () => 'encrypted', +})); + +vi.mock('@/lib/stalwart/auth-context', () => ({ + setStalwartAuthContext: vi.fn(), + setStalwartAuthContextInStore: vi.fn(), + clearStalwartAuthContextInStore: vi.fn(), + readStalwartAuthContextFromStore: () => null, +})); + +vi.mock('@/lib/telemetry/login-tracker', () => ({ recordLogin: vi.fn() })); + +const resolveJmapIdentity = vi.hoisted(() => vi.fn()); +vi.mock('@/lib/auth/verify-jmap-auth', async (original) => ({ + ...(await original()), + resolveJmapIdentity, +})); + +const reserveLoginAttempt = vi.hoisted(() => vi.fn()); +vi.mock('@/lib/auth/verify-budget', async (original) => ({ + ...(await original()), + reserveLoginAttempt, +})); + +// The route's first import compiles a good part of the app. +vi.setConfig({ testTimeout: 60_000 }); + +const TRUST_KEY = Symbol.for('bulwark.desktop.trustedJmapOrigins'); +const LAN_SERVER = 'http://192.168.1.5:8080'; + +function loginRequest(serverUrl: string): unknown { + const headers = new Headers({ 'sec-fetch-site': 'same-origin', host: 'app.bulwark.localhost' }); + return { + method: 'POST', + headers, + nextUrl: { searchParams: new URLSearchParams(), basePath: '' }, + json: async () => ({ serverUrl, username: 'ent-alice@example.org', password: 'pw' }), + }; +} + +async function postSession(serverUrl: string) { + vi.resetModules(); + const { POST } = await import('@/app/api/auth/session/route'); + const res = (await POST(loginRequest(serverUrl) as Parameters[0])) as unknown as { + status: number; + json: () => Promise>; + }; + return { status: res.status, body: await res.json() }; +} + +beforeEach(() => { + resolveJmapIdentity.mockReset(); + resolveJmapIdentity.mockImplementation(async (url: string) => ({ serverUrl: url, accountName: 'ent-alice@example.org' })); + reserveLoginAttempt.mockReset(); + reserveLoginAttempt.mockImplementation(async () => async () => {}); + (globalThis as unknown as Record>)[TRUST_KEY] = new Set([LAN_SERVER]); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + delete (globalThis as unknown as Record)[TRUST_KEY]; +}); + +describe('POST /api/auth/session', () => { + it('a server build refuses a private URL, even with the set present', async () => { + const { status, body } = await postSession(LAN_SERVER); + expect(status).toBe(400); + expect(body.error).toBe('Server URL is not allowed'); + expect(resolveJmapIdentity).not.toHaveBeenCalled(); + }); + + it('a desktop build still refuses a private server the user did not approve', async () => { + vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1'); + const { status } = await postSession('http://192.168.1.6:8080'); + expect(status).toBe(400); + expect(resolveJmapIdentity).not.toHaveBeenCalled(); + }); + + it('a desktop build reaches an approved private server, and marks it trusted', async () => { + vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1'); + const { status } = await postSession(LAN_SERVER); + expect(status).toBe(200); + expect(resolveJmapIdentity).toHaveBeenCalledTimes(1); + expect(resolveJmapIdentity.mock.calls[0][3]).toMatchObject({ trusted: true }); + }); +}); + +describe('the login budget', () => { + it('a server build budgets by client address', async () => { + config.jmapServers = [{ id: 'a', url: LAN_SERVER }]; + await postSession(LAN_SERVER); + delete config.jmapServers; + expect(reserveLoginAttempt).toHaveBeenCalledWith('0.0.0.0'); + }); + + it('a desktop build budgets per mail server, so two servers do not share one', async () => { + vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1'); + (globalThis as unknown as Record>)[TRUST_KEY] = new Set([LAN_SERVER, 'http://192.168.1.6:8080']); + await postSession(LAN_SERVER); + await postSession('http://192.168.1.6:8080/'); + expect(reserveLoginAttempt.mock.calls.map((c) => c[0])).toEqual([ + 'server:http://192.168.1.5:8080', + 'server:http://192.168.1.6:8080', + ]); + }); + + it('the key is no address, so nothing is forwarded upstream as the client', async () => { + const { forwardedClientHeaders } = await import('@/lib/auth/client-address'); + expect(forwardedClientHeaders('server:http://192.168.1.5:8080')).toEqual({}); + }); +}); diff --git a/lib/desktop/jmap-route.ts b/lib/desktop/jmap-route.ts new file mode 100644 index 000000000..491b2b4e3 --- /dev/null +++ b/lib/desktop/jmap-route.ts @@ -0,0 +1,42 @@ +/** + * Where the page reaches a plain http mail server in the desktop app. + * + * The app's page is https, so Chromium blocks a fetch to http://192.168.x as + * mixed content, whatever the CSP says. The embedded server forwards + * `/__jmap//...` to servers the user approved (src/server/jmap-proxy.ts in + * the desktop repo), where the id is the base64url of the server's origin. The + * real URL stays in the account registry and the cookies, since the server + * routes reach the mail server directly: only the page's own requests move. + */ + +import { IS_DESKTOP_BUILD } from './flag'; + +export function desktopJmapOriginId(origin: string): string { + const bytes = new TextEncoder().encode(origin); + let binary = ''; + for (const byte of bytes) binary += String.fromCharCode(byte); + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); +} + +/** + * The base URL the page uses to talk to `serverUrl`: the same URL unless this + * is the desktop build and the server speaks plain http. + */ +export function desktopJmapBase(serverUrl: string): string { + if (!IS_DESKTOP_BUILD || typeof window === 'undefined') return serverUrl; + try { + const url = new URL(serverUrl); + if (url.protocol !== 'http:') return serverUrl; + const path = url.pathname.replace(/\/+$/, ''); + return `${window.location.origin}/__jmap/${desktopJmapOriginId(url.origin)}${path}`; + } catch { + return serverUrl; + } +} + +/** The origin part of {@link desktopJmapBase}, onto which session URLs are moved. */ +export function desktopJmapOrigin(serverUrl: string): string { + const { origin, protocol } = new URL(serverUrl); + if (!IS_DESKTOP_BUILD || typeof window === 'undefined' || protocol !== 'http:') return origin; + return `${window.location.origin}/__jmap/${desktopJmapOriginId(origin)}`; +} diff --git a/lib/desktop/login-budget.ts b/lib/desktop/login-budget.ts new file mode 100644 index 000000000..a630a8153 --- /dev/null +++ b/lib/desktop/login-budget.ts @@ -0,0 +1,17 @@ +/** + * The key the sign-in routes budget failures on, in the desktop build. + * + * There's one client on the embedded server, so a client key would make every + * account on the machine share five failures per 15 minutes: a stale password + * on one server would lock sign-in on the others. What the budget protects is + * the mail server's failure allowance for this address, so it counts per + * server. The value is no IP address, so forwardedClientHeaders passes nothing + * upstream. + */ +export function desktopLoginBudgetKey(serverUrl: unknown): string { + try { + return `server:${new URL(String(serverUrl)).origin}`; + } catch { + return 'server:invalid'; + } +} diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index 6d35dcdfa..8554ae898 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -1,4 +1,6 @@ import { recordJmapCall, reportJmapFailure } from "@/lib/error-reporting/client"; +import { getDesktopBridge } from '@/lib/desktop/bridge'; +import { desktopJmapBase, desktopJmapOrigin } from '@/lib/desktop/jmap-route'; import { advertisesMailboxMetadata, bulwarkEntries, escapeMetadataPathSegment, MAX_METADATA_ENTRY_BYTES, METADATA_CAPABILITY, MetadataPatchError, MetadataRefusedError, MetadataTooLargeError, MetadataUnsupportedError, metadataValueBytes, namespaceFor } from "./bulwark-metadata"; import { IncompleteDeleteError } from "./delete-error"; import { downloadAllowed, saveBlobAs, type DownloadKind } from "@/lib/download-guard"; @@ -1887,7 +1889,7 @@ export class JMAPClient implements IJMAPClient { const retryHeaders = { ...init?.headers as Record, 'Authorization': this.authHeader }; response = await this.timedFetch(url, init, retryHeaders, timeoutMs); } - } else if (this.authMode === 'basic' && !this.reconnecting && url !== `${this.serverUrl}/.well-known/jmap`) { + } else if (this.authMode === 'basic' && !this.reconnecting && url !== this.discoveryUrl()) { // JMAP session may have expired - re-establish and retry once this.reconnecting = true; try { @@ -1937,7 +1939,7 @@ export class JMAPClient implements IJMAPClient { * and retry directly against the final URL so we can re-send Authorization. */ private async fetchSessionResponse(): Promise { - const discoveryUrl = `${this.serverUrl}/.well-known/jmap`; + const discoveryUrl = this.discoveryUrl(); const response = await this.authenticatedFetch(discoveryUrl, { method: 'GET' }); // A proxy in front of the session URL answers the header-less redirected // request with 401 instead of Stalwart's silent 200 - the credentials are @@ -1980,7 +1982,7 @@ export class JMAPClient implements IJMAPClient { async connect(): Promise { if (this.offlineOnly) throw new OfflineUnavailableError(); this.intentionallyDisconnected = false; - const sessionUrl = `${this.serverUrl}/.well-known/jmap`; + const sessionUrl = this.discoveryUrl(); try { const sessionResponse = await this.fetchSessionResponse(); @@ -2137,10 +2139,15 @@ export class JMAPClient implements IJMAPClient { this.capabilities = {}; } + /** Same as serverUrl, except for a plain http server in the desktop app (lib/desktop/jmap-route.ts). */ + private discoveryUrl(): string { + return `${desktopJmapBase(this.serverUrl)}/.well-known/jmap`; + } + private rewriteSessionUrl(url: string): string { if (!url) return url; try { - const { origin } = new URL(this.serverUrl); + const origin = desktopJmapOrigin(this.serverUrl); if (!/^(https?:)?\/\//i.test(url)) { return origin + (url.startsWith('/') ? url : '/' + url); } @@ -2173,6 +2180,14 @@ export class JMAPClient implements IJMAPClient { if (session.eventSourceUrl) { session.eventSourceUrl = rewrite(session.eventSourceUrl); } + // A download or upload origin of its own needs the CORS headers the desktop + // app adds for this account's servers. + getDesktopBridge()?.servers?.noteSession(this.serverUrl, { + apiUrl: session.apiUrl, + downloadUrl: session.downloadUrl, + uploadUrl: session.uploadUrl, + eventSourceUrl: session.eventSourceUrl, + }); } /** diff --git a/lib/offline/offline-mode.ts b/lib/offline/offline-mode.ts index 0152db852..c9badd35e 100644 --- a/lib/offline/offline-mode.ts +++ b/lib/offline/offline-mode.ts @@ -20,6 +20,7 @@ */ import { IS_LITE } from '@/lib/lite'; +import { desktopJmapBase } from '@/lib/desktop/jmap-route'; import { devicesForAccount } from '@/lib/device-policy'; import { accountSubjectPending, useAccountSubjects } from '@/lib/org/account-subjects'; import { labelsPolicyFor } from '@/lib/outgoing/classification'; @@ -324,7 +325,7 @@ export function probeServer(serverUrl: string): Promise { if (running) return running; const probe = (async () => { try { - await fetch(`${serverUrl.replace(/\/+$/, '')}/.well-known/jmap`, { mode: 'no-cors', cache: 'no-store', credentials: 'omit' }); + await fetch(`${desktopJmapBase(serverUrl.replace(/\/+$/, ''))}/.well-known/jmap`, { mode: 'no-cors', cache: 'no-store', credentials: 'omit' }); if (setServerDown(serverUrl, false)) scheduleFlushAll(500); return false; } catch { From aa511e5d38aed30ef026cd5ef46543460ad4ae98 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 06:32:43 +0100 Subject: [PATCH 3/7] feat: sign in through the system browser in the desktop app, and refresh at the account's own server --- app/(main)/[locale]/auth/callback/page.tsx | 13 +- app/(main)/[locale]/login/page.tsx | 62 +++++ app/api/auth/desktop-oauth/route.ts | 86 +++++++ app/api/auth/token/route.ts | 46 +++- app/api/auth/totp-token-exchange/route.ts | 17 +- lib/auth/crypto.ts | 5 +- lib/desktop/__tests__/browser-side.test.ts | 233 ++++++++++++++++++ lib/desktop/__tests__/custom-upstream.test.ts | 231 +++++++++++++++++ lib/desktop/browser-sign-in.ts | 104 ++++++++ lib/oauth/custom-upstream.ts | 72 ++++++ lib/oauth/token-exchange.ts | 21 +- locales/ar/common.json | 9 + locales/ca/common.json | 9 + locales/cs/common.json | 9 + locales/da/common.json | 9 + locales/de/common.json | 9 + locales/en/common.json | 9 + locales/es/common.json | 9 + locales/et/common.json | 9 + locales/fa/common.json | 9 + locales/fr/common.json | 9 + locales/he/common.json | 9 + locales/hu/common.json | 9 + locales/it/common.json | 9 + locales/ja/common.json | 9 + locales/ko/common.json | 9 + locales/lv/common.json | 9 + locales/mn/common.json | 9 + locales/nb/common.json | 9 + locales/nl/common.json | 9 + locales/pl/common.json | 9 + locales/pt/common.json | 9 + locales/ro/common.json | 9 + locales/ru/common.json | 9 + locales/sk/common.json | 9 + locales/th/common.json | 9 + locales/tr/common.json | 9 + locales/uk/common.json | 9 + locales/vi/common.json | 9 + locales/zh-TW/common.json | 9 + locales/zh/common.json | 9 + stores/auth-store.ts | 13 + 42 files changed, 1153 insertions(+), 20 deletions(-) create mode 100644 app/api/auth/desktop-oauth/route.ts create mode 100644 lib/desktop/__tests__/browser-side.test.ts create mode 100644 lib/desktop/__tests__/custom-upstream.test.ts create mode 100644 lib/desktop/browser-sign-in.ts create mode 100644 lib/oauth/custom-upstream.ts diff --git a/app/(main)/[locale]/auth/callback/page.tsx b/app/(main)/[locale]/auth/callback/page.tsx index 97bace353..e3862393a 100644 --- a/app/(main)/[locale]/auth/callback/page.tsx +++ b/app/(main)/[locale]/auth/callback/page.tsx @@ -1,6 +1,8 @@ "use client"; import { Suspense, useEffect, useRef, useState } from "react"; +import { IS_DESKTOP_BUILD } from "@/lib/desktop/flag"; +import { DESKTOP_CLIENT_ID_KEY, DESKTOP_REDIRECT_URI_KEY } from "@/lib/desktop/browser-sign-in"; import { useRouter, useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; import { useAuthStore } from "@/stores/auth-store"; @@ -116,9 +118,12 @@ function OAuthCallbackInner() { const prefix = getPathPrefix(params.locale as string); // Lite on Stalwart registers one locale-free URI (lib/auth/lite-oauth.ts); // the entry document brought us here from it. - const redirectUri = LITE_OAUTH_AVAILABLE + // The desktop app's browser sign-in redirected to its loopback listener + // (lib/desktop/browser-sign-in.ts), which this page's origin can't tell. + const desktopRedirectUri = IS_DESKTOP_BUILD ? sessionStorage.getItem(DESKTOP_REDIRECT_URI_KEY) : null; + const redirectUri = desktopRedirectUri ?? (LITE_OAUTH_AVAILABLE ? getLiteOAuthRedirectUri() - : `${window.location.origin}${prefix}/${params.locale}/auth/callback`; + : `${window.location.origin}${prefix}/${params.locale}/auth/callback`); loginWithOAuth(serverUrl, code, codeVerifier, redirectUri, serverId) .then((success) => { @@ -128,6 +133,10 @@ function OAuthCallbackInner() { sessionStorage.removeItem("oauth_server_url"); sessionStorage.removeItem("oauth_server_id"); sessionStorage.removeItem("oauth_add_account_mode"); + if (IS_DESKTOP_BUILD) { + sessionStorage.removeItem(DESKTOP_REDIRECT_URI_KEY); + sessionStorage.removeItem(DESKTOP_CLIENT_ID_KEY); + } let redirectTo = `${prefix}/${params.locale}`; try { const saved = sessionStorage.getItem('redirect_after_login'); diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index 1ef9932e0..d28ae4039 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -1,6 +1,9 @@ "use client"; import { Suspense, useState, useEffect, useRef, useCallback, useId } from "react"; +import { IS_DESKTOP_BUILD } from "@/lib/desktop/flag"; +import { getDesktopBridge } from "@/lib/desktop/bridge"; +import { browserSignInAvailable, runDesktopBrowserSignIn } from "@/lib/desktop/browser-sign-in"; import { useRouter } from "@/i18n/navigation"; import { useParams, useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; @@ -185,6 +188,10 @@ function VersionBadge() { function LoginPageContent() { const router = useRouter(); const t = useTranslations("login"); + const tDesktop = useTranslations("desktop_sign_in"); + // Only the desktop build sets these; a browser never shows them. + const [desktopNotice, setDesktopNotice] = useState(null); + const [desktopWaiting, setDesktopWaiting] = useState(false); const tSessionLimits = useTranslations("session_limits"); const tOffline = useTranslations("offline"); const tTheme = useTranslations("settings.appearance.theme"); @@ -985,6 +992,33 @@ function LoginPageContent() { window.location.href = authUrl.toString(); }; + /** The desktop build: the server's own sign-in page in the system browser. */ + const handleDesktopBrowserSignIn = async () => { + const target = jmapEndpoint.trim(); + if (!target) { + setDesktopNotice(tDesktop("server_needed")); + return; + } + setDesktopNotice(null); + setDesktopWaiting(true); + const prefix = getPathPrefix(params.locale as string); + const outcome = await runDesktopBrowserSignIn({ + serverUrl: target, + callbackPath: `${prefix}/${params.locale}/auth/callback`, + slot: useAccountStore.getState().getNextCookieSlot(), + remember: persistProviderSession, + addAccount: isAddAccountMode, + }); + if (outcome.kind === "callback") { + window.location.href = outcome.callbackUrl; + return; + } + setDesktopWaiting(false); + if (outcome.kind === "unavailable") setDesktopNotice(tDesktop("browser_unavailable")); + else if (outcome.kind === "declined") setDesktopNotice(tDesktop("server_declined")); + else if (outcome.kind === "failed") setDesktopNotice(tDesktop("browser_failed")); + }; + const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -992,6 +1026,16 @@ function LoginPageContent() { // when the admin hasn't configured a server list. const effectiveServerUrl = selectedServer?.url || (allowCustomJmapEndpoint ? jmapEndpoint : serverUrl); + // A LAN or plain http server gets a native prompt first, and the app can + // reach it from then on. Without a yes, nothing is sent there. + if (IS_DESKTOP_BUILD) { + setDesktopNotice(null); + const prepared = await getDesktopBridge()?.servers?.prepare(effectiveServerUrl); + if (prepared && !prepared.ok) { + setDesktopNotice(prepared.reason === "invalid" ? tDesktop("server_invalid") : tDesktop("server_declined")); + return; + } + } if (signInWithToken) { if (await loginWithToken(effectiveServerUrl, accessToken, persistSession)) { router.push('/'); @@ -1814,6 +1858,24 @@ function LoginPageContent() { )} + {IS_DESKTOP_BUILD && !hasServerList && browserSignInAvailable() && ( + + )} + {IS_DESKTOP_BUILD && desktopNotice && ( +

+ {desktopNotice} +

+ )} {((serverOauthEnabled && oauthDiscoveryDone && !oauthMetadata) || liteOAuthFailed) && (
diff --git a/app/api/auth/desktop-oauth/route.ts b/app/api/auth/desktop-oauth/route.ts new file mode 100644 index 000000000..c6fa99102 --- /dev/null +++ b/app/api/auth/desktop-oauth/route.ts @@ -0,0 +1,86 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { logger } from '@/lib/logger'; +import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag'; +import { discoverOAuth } from '@/lib/oauth/discovery'; +import { DEFAULT_OAUTH_SCOPES, withProviderDataScopes } from '@/lib/oauth/scopes'; +import { getDiscoveryValidator } from '@/lib/oauth/token-exchange'; +import { resolveCustomUpstream } from '@/lib/oauth/custom-upstream'; +import { rejectCrossOriginRequest } from '@/lib/security/same-origin'; + +/** The client id on a server that won't register one: 0.16 accepts any id. */ +const FALLBACK_CLIENT_ID = 'bulwark-desktop'; +const REGISTERED_REDIRECT = 'http://127.0.0.1/bulwark-desktop/callback'; +const REGISTER_TIMEOUT_MS = 5000; + +function isLoopbackRedirect(value: unknown): boolean { + if (typeof value !== 'string') return false; + try { + const url = new URL(value); + return url.protocol === 'http:' && url.hostname === '127.0.0.1' && url.pathname === '/bulwark-desktop/callback'; + } catch { + return false; + } +} + +/** + * Registers the desktop app as a public client. Stalwart 1.0 needs this: its + * ids are stateless, so the same metadata gives the same id and nothing is + * stored. It accepts a loopback redirect and ignores the port when matching. + */ +async function registerClient(base: string, scope: string): Promise { + try { + const response = await fetch(`${base}/auth/register`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + client_name: 'Bulwark', + redirect_uris: [REGISTERED_REDIRECT], + token_endpoint_auth_method: 'none', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + scope, + }), + redirect: 'error', + signal: AbortSignal.timeout(REGISTER_TIMEOUT_MS), + }); + if (!response.ok) return FALLBACK_CLIENT_ID; + const body = (await response.json()) as { client_id?: unknown }; + return typeof body.client_id === 'string' && body.client_id ? body.client_id : FALLBACK_CLIENT_ID; + } catch (error) { + logger.warn('Desktop OAuth: client registration failed', { error: error instanceof Error ? error.message : String(error) }); + return FALLBACK_CLIENT_ID; + } +} + +/** + * What the desktop app needs to send the user to a server's own sign-in page: + * the authorize endpoint and a client id. Only in the desktop build. Discovery + * runs here rather than in the page, for the same reason as on the login page: + * the documents often have no CORS headers. + */ +export async function POST(request: NextRequest) { + if (!IS_DESKTOP_BUILD) return NextResponse.json({ error: 'Not found' }, { status: 404 }); + const crossOrigin = rejectCrossOriginRequest(request); + if (crossOrigin) return crossOrigin; + try { + const { serverUrl, redirectUri } = await request.json(); + if (!isLoopbackRedirect(redirectUri)) { + return NextResponse.json({ error: 'invalid_redirect' }, { status: 400 }); + } + const upstream = await resolveCustomUpstream(serverUrl, FALLBACK_CLIENT_ID); + if (!upstream) return NextResponse.json({ error: 'invalid_server_url' }, { status: 400 }); + + const metadata = await discoverOAuth(upstream.serverUrl, { validateEndpoint: getDiscoveryValidator(upstream.serverUrl) }); + if (!metadata) return NextResponse.json({ error: 'no_oauth' }, { status: 404 }); + + const scope = withProviderDataScopes(DEFAULT_OAUTH_SCOPES, metadata.scopes_supported); + return NextResponse.json({ + authorization_endpoint: metadata.authorization_endpoint, + client_id: await registerClient(upstream.serverUrl, scope), + scope, + }); + } catch (error) { + logger.error('Desktop OAuth discovery failed', { error: error instanceof Error ? error.message : 'Unknown error' }); + return NextResponse.json({ error: 'discovery_failed' }, { status: 503 }); + } +} diff --git a/app/api/auth/token/route.ts b/app/api/auth/token/route.ts index 6c065e524..3e6a90667 100644 --- a/app/api/auth/token/route.ts +++ b/app/api/auth/token/route.ts @@ -41,6 +41,14 @@ import { writeSignInStamp, } from '@/lib/auth/session-age'; import { clearStalwartAuthContextInStore, readStalwartAuthContextFromStore } from '@/lib/stalwart/auth-context'; +import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag'; +import { + clearCustomUpstream, + readCustomUpstream, + resolveCustomUpstream, + writeCustomUpstream, + type CustomUpstream, +} from '@/lib/oauth/custom-upstream'; function parseSlot(raw: string | null): number | null { if (raw === null) return null; @@ -57,13 +65,13 @@ function getSlot(request: NextRequest): number { // not hold it up for long. const REVOCATION_TIMEOUT_MS = 3000; -async function revokeRefreshToken(token: string, serverId: string | null, metadata: OAuthMetadata | null): Promise { +async function revokeRefreshToken(token: string, serverId: string | null, metadata: OAuthMetadata | null, upstream: CustomUpstream | null): Promise { const revocationEndpoint = metadata?.revocation_endpoint; if (!revocationEndpoint) return; const params = buildOAuthParams({ token, token_type_hint: 'refresh_token', - }, serverId, await defaultClientOptions(serverId).catch(() => ({ fallbackClientId: DEFAULT_CLIENT_ID }))); + }, serverId, await defaultClientOptions(serverId, upstream).catch(() => ({ fallbackClientId: DEFAULT_CLIENT_ID }))); try { // Never follow a redirect with the refresh token and client secret in @@ -90,12 +98,12 @@ async function revokeRefreshToken(token: string, serverId: string | null, metada * Stalwart, and for a logout page shared with other apps, which must keep * working once the provider starts advertising its own. */ -function endSessionUrlFor(metadata: OAuthMetadata | null, serverId: string | null, idToken: string | undefined): string | null { +function endSessionUrlFor(metadata: OAuthMetadata | null, serverId: string | null, idToken: string | undefined, upstream: CustomUpstream | null): string | null { if (!isEndSessionEnabled()) return null; let clientId: string; let configuredEndpoint: string | undefined; try { - ({ clientId, endSessionUrl: configuredEndpoint } = getRequiredConfig(serverId)); + ({ clientId, endSessionUrl: configuredEndpoint } = upstream ? getRequiredConfig(serverId, { upstream }) : getRequiredConfig(serverId)); } catch { return null; } @@ -146,7 +154,7 @@ export async function POST(request: NextRequest) { const crossOrigin = rejectCrossOriginRequest(request); if (crossOrigin) return crossOrigin; try { - const { code, code_verifier, redirect_uri, slot: bodySlot, server_id: bodyServerId, remember: bodyRemember } = await request.json(); + const { code, code_verifier, redirect_uri, slot: bodySlot, server_id: bodyServerId, remember: bodyRemember, custom_server_url: customServerUrl, client_id: bodyClientId } = await request.json(); // "Remember me" left unticked: the cookies end with the browser session. // Clients from before the choice remembered every sign-in. const remember = bodyRemember !== false; @@ -158,7 +166,14 @@ export async function POST(request: NextRequest) { const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot < MAX_ACCOUNT_SLOTS ? bodySlot : getSlot(request); const serverId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null; - const tokens = await exchangeCodeForTokens(code, code_verifier, redirect_uri, serverId); + // The desktop app signs in to a server the user typed, with a client it + // registered there. Nothing configured here knows about either. + const customUpstream = IS_DESKTOP_BUILD && !serverId ? await resolveCustomUpstream(customServerUrl, bodyClientId) : null; + if (IS_DESKTOP_BUILD && !serverId && !customUpstream) { + return NextResponse.json({ error: 'invalid_server_url' }, { status: 400 }); + } + + const tokens = await exchangeCodeForTokens(code, code_verifier, redirect_uri, serverId, customUpstream ? { upstream: customUpstream } : undefined); // A sign-in: the maximum session length counts from here, on the server // and, from this answer, in the browser. const signedInAt = Date.now(); @@ -187,6 +202,7 @@ export async function POST(request: NextRequest) { } else if (cookieStore.get(serverCookieName)) { cookieStore.delete(serverCookieName); } + if (customUpstream) writeCustomUpstream(cookieStore, slot, customUpstream, remember); return response; } catch (error) { @@ -257,12 +273,13 @@ export async function PUT(request: NextRequest) { // The refresh token may have been minted by the password+TOTP login route, // which works without a configured OAuth client by falling back to the // default client id - refreshing must fall back the same way (#873). - const tokenEndpoint = await getTokenEndpoint(serverId, { fallbackClientId: DEFAULT_CLIENT_ID }); + const upstream = IS_DESKTOP_BUILD ? await readCustomUpstream(cookieStore, slot) : null; + const tokenEndpoint = await getTokenEndpoint(serverId, upstream ? { upstream } : { fallbackClientId: DEFAULT_CLIENT_ID }); const params = buildOAuthParams({ grant_type: 'refresh_token', refresh_token: refreshToken, - }, serverId, await defaultClientOptions(serverId)); + }, serverId, await defaultClientOptions(serverId, upstream)); const tokenResponse = await observeUpstream('oauth', () => fetch(tokenEndpoint, { method: 'POST', @@ -283,6 +300,7 @@ export async function PUT(request: NextRequest) { cookieStore.delete(cookieName); cookieStore.delete(refreshTokenServerCookieName(slot)); cookieStore.delete(accessTokenCookieName(slot)); + clearCustomUpstream(cookieStore, slot); clearIdToken(cookieStore, slot, request.nextUrl.basePath); return NextResponse.json({ error: 'Refresh failed' }, { status: 401 }); } @@ -324,6 +342,10 @@ export async function PUT(request: NextRequest) { }); } catch (error) { logger.error('Token refresh error', { error: error instanceof Error ? error.message : 'Unknown error' }); + // On the desktop this route runs on the user's machine, so an unreachable + // mail server lands here. The client signs out after repeated 500s; a 503 + // tells it to keep the session and try again. + if (IS_DESKTOP_BUILD) return NextResponse.json({ error: 'Token endpoint unavailable' }, { status: 503 }); return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); } } @@ -343,19 +365,20 @@ async function signOutSlot( const refreshToken = cookieStore.get(cookieName)?.value; const serverId = cookieStore.get(refreshTokenServerCookieName(slot))?.value || null; const idToken = cookieStore.get(idTokenCookieName(slot))?.value; + const upstream = IS_DESKTOP_BUILD ? await readCustomUpstream(cookieStore, slot) : null; // With revocation off (#1388) the token is only dropped from the browser. const tokenToRevoke = refreshToken && isRevokeOnSignOutEnabled() ? refreshToken : undefined; let endSessionUrl: string | null = null; if (tokenToRevoke || endSession) { - const metadata = await getMetadata(serverId, { fallbackClientId: DEFAULT_CLIENT_ID }).catch((err) => { + const metadata = await getMetadata(serverId, upstream ? { upstream } : { fallbackClientId: DEFAULT_CLIENT_ID }).catch((err) => { logger.warn('Failed to discover OAuth metadata during logout', { error: err instanceof Error ? err.message : 'Unknown error', }); return null; }); - if (tokenToRevoke) await revokeRefreshToken(tokenToRevoke, serverId, metadata); - if (endSession) endSessionUrl = endSessionUrlFor(metadata, serverId, idToken); + if (tokenToRevoke) await revokeRefreshToken(tokenToRevoke, serverId, metadata, upstream); + if (endSession) endSessionUrl = endSessionUrlFor(metadata, serverId, idToken, upstream); } // Cleared whether or not revocation worked: the browser must not be able @@ -366,6 +389,7 @@ async function signOutSlot( if (cookieStore.get(name)) cookieStore.delete(name); } clearSignInStamp(cookieStore, slot); + if (IS_DESKTOP_BUILD) clearCustomUpstream(cookieStore, slot); if (idToken !== undefined) clearIdToken(cookieStore, slot, basePath); return endSessionUrl; } diff --git a/app/api/auth/totp-token-exchange/route.ts b/app/api/auth/totp-token-exchange/route.ts index 4c374a4f3..46e77ac33 100644 --- a/app/api/auth/totp-token-exchange/route.ts +++ b/app/api/auth/totp-token-exchange/route.ts @@ -16,6 +16,10 @@ import { loginClientAddress } from '@/lib/auth/client-address'; import { LOGIN_BUDGET_RETRY_AFTER_SECONDS, reserveLoginAttempt } from '@/lib/auth/verify-budget'; import { isStoreUnavailable, storeUnavailableResponse } from '@/lib/auth/store-unavailable'; import { writeSignInStamp } from '@/lib/auth/session-age'; +import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag'; +import { isDesktopTrustedServer } from '@/lib/desktop/trusted-servers'; +import { stalwartDefaultClientId } from '@/lib/auth/stalwart-password-login'; +import { writeCustomUpstream } from '@/lib/oauth/custom-upstream'; /** * Exchange a password + (optional) TOTP code for OAuth tokens. @@ -113,7 +117,12 @@ async function attemptLogin( logger.info('TOTP login succeeded'); void recordLogin(username, base); - return await storeAndRespond(result.tokens, slot, serverId, rememberMe); + // The desktop app's servers are in no configured list, so the refresh has to + // be told where this token belongs and which client it was issued to. + const upstream = IS_DESKTOP_BUILD && !serverId + ? { serverUrl: base, clientId: await stalwartDefaultClientId(base, clientId, upstreamTrusted) } + : null; + return await storeAndRespond(result.tokens, slot, serverId, rememberMe, upstream); } function trimUrl(url: string): string { @@ -164,6 +173,8 @@ export async function POST(request: NextRequest) { resolvedServerId = matchedEntry.id; } else if (configuredServerUrl && !customRequested) { upstreamUrl = configuredServerUrl; + } else if (IS_DESKTOP_BUILD && isDesktopTrustedServer(serverUrl)) { + upstreamUrl = serverUrl; } else if (allowCustomEndpoint) { if (!(await isPublicHttpUrl(serverUrl))) { logger.warn('TOTP login: rejected non-public server URL'); @@ -193,7 +204,7 @@ export async function POST(request: NextRequest) { slot, resolvedServerId, upstreamTrusted, - loginClientAddress(request), + loginClientAddress(request, upstreamUrl), // Clients from before the flag remembered every sign-in. rememberMe !== false, ); @@ -210,6 +221,7 @@ async function storeAndRespond( slot: number, serverId: string | null, rememberMe: boolean, + upstream: { serverUrl: string; clientId: string } | null, ): Promise { const cookieStore = await cookies(); // A sign-in: the maximum session length counts from here (lib/auth/session-age.ts). @@ -227,6 +239,7 @@ async function storeAndRespond( } else { cookieStore.delete(serverCookieName); } + if (upstream && tokens.refresh_token) writeCustomUpstream(cookieStore, slot, upstream, rememberMe); return NextResponse.json({ access_token: tokens.access_token, diff --git a/lib/auth/crypto.ts b/lib/auth/crypto.ts index 50e053e35..a16fd7958 100644 --- a/lib/auth/crypto.ts +++ b/lib/auth/crypto.ts @@ -121,7 +121,9 @@ export type PayloadPurpose = /** A pairing grant or code record in the shared store (lib/auth/pairing-store.ts). */ | 'pairing-store' /** A parked mobile hand-off sign-in in the shared store (lib/auth/handoff-store.ts). */ - | 'handoff-store'; + | 'handoff-store' + /** Which server a desktop account's refresh token belongs to (lib/oauth/custom-upstream.ts). */ + | 'custom-upstream'; /** * Whether a blob minted before purposes were bound may be re-admitted for @@ -145,6 +147,7 @@ const LEGACY_PAYLOAD_MARKERS: Record false, 'pairing-store': () => false, 'handoff-store': () => false, + 'custom-upstream': () => false, }; export function encryptPayload(payload: Record, purpose: PayloadPurpose): string { diff --git a/lib/desktop/__tests__/browser-side.test.ts b/lib/desktop/__tests__/browser-side.test.ts new file mode 100644 index 000000000..6d7d8d23a --- /dev/null +++ b/lib/desktop/__tests__/browser-side.test.ts @@ -0,0 +1,233 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { BulwarkDesktopBridge } from '../types'; + +/** The webmail's view of the bridge, and the http server route. */ + +// The client's first import compiles a good part of the app. +vi.setConfig({ testTimeout: 60_000 }); + +function setBridge(bridge: Partial | undefined) { + window.bulwarkDesktop = bridge as BulwarkDesktopBridge | undefined; +} + +afterEach(() => { + setBridge(undefined); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); + sessionStorage.clear(); +}); + +describe('feature detection', () => { + it('finds nothing in a browser', async () => { + const { getDesktopBridge, isDesktopApp } = await import('../bridge'); + expect(getDesktopBridge()).toBeNull(); + expect(isDesktopApp()).toBe(false); + }); + + it('finds the bridge in the app', async () => { + const { isDesktopApp } = await import('../bridge'); + setBridge({ version: 1, platform: 'win32' }); + expect(isDesktopApp()).toBe(true); + }); + + it('treats a newer bridge major as absent rather than guessing at it', async () => { + const { getDesktopBridge } = await import('../bridge'); + setBridge({ version: 2, platform: 'win32' }); + expect(getDesktopBridge()).toBeNull(); + }); +}); + +describe('web push', () => { + beforeEach(() => { + Object.defineProperty(navigator, 'serviceWorker', { value: {}, configurable: true }); + vi.stubGlobal('PushManager', function PushManager() {}); + vi.stubGlobal('Notification', function Notification() {}); + }); + + it('is on offer in a browser', async () => { + const { isWebPushSupported } = await import('@/lib/web-push'); + expect(isWebPushSupported()).toBe(true); + }); + + it('is off in the app: Electron has the API, but subscribing fails', async () => { + const { isWebPushSupported } = await import('@/lib/web-push'); + setBridge({ version: 1, platform: 'win32' }); + expect(isWebPushSupported()).toBe(false); + }); +}); + +describe('desktopJmapBase', () => { + async function load() { + vi.resetModules(); + return import('../jmap-route'); + } + + it('leaves every URL alone outside the desktop build', async () => { + const { desktopJmapBase, desktopJmapOrigin } = await load(); + expect(desktopJmapBase('http://192.168.1.5:8080')).toBe('http://192.168.1.5:8080'); + expect(desktopJmapOrigin('http://192.168.1.5:8080')).toBe('http://192.168.1.5:8080'); + }); + + it('moves a plain http server onto the same-origin route', async () => { + vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1'); + const { desktopJmapBase, desktopJmapOriginId } = await load(); + const id = desktopJmapOriginId('http://192.168.1.5:8080'); + expect(id).toMatch(/^[A-Za-z0-9_-]+$/); + expect(Buffer.from(id, 'base64url').toString()).toBe('http://192.168.1.5:8080'); + expect(desktopJmapBase('http://192.168.1.5:8080')).toBe(`${window.location.origin}/__jmap/${id}`); + }); + + it('keeps an https server direct', async () => { + vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1'); + const { desktopJmapBase, desktopJmapOrigin } = await load(); + expect(desktopJmapBase('https://mail.lan')).toBe('https://mail.lan'); + expect(desktopJmapOrigin('https://mail.lan')).toBe('https://mail.lan'); + }); +}); + +describe('JMAPClient on an approved http server', () => { + const SESSION = { + capabilities: { 'urn:ietf:params:jmap:core': { maxObjectsInGet: 500 }, 'urn:ietf:params:jmap:mail': {} }, + accounts: { a1: { name: 'ent-alice', isPersonal: true, isReadOnly: false, accountCapabilities: { 'urn:ietf:params:jmap:mail': {} } } }, + primaryAccounts: { 'urn:ietf:params:jmap:mail': 'a1' }, + username: 'ent-alice', + apiUrl: 'http://stalwart.local:8080/jmap/', + downloadUrl: 'http://stalwart.local:8080/jmap/download/{accountId}/{blobId}/{name}?accept={type}', + uploadUrl: 'http://stalwart.local:8080/jmap/upload/{accountId}/', + eventSourceUrl: 'http://stalwart.local:8080/jmap/eventsource/', + state: 's1', + }; + + function stubFetch() { + const urls: string[] = []; + vi.stubGlobal('fetch', vi.fn(async (input: string | URL | Request) => { + const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url; + urls.push(url); + if (url.includes('/.well-known/jmap')) return new Response(JSON.stringify(SESSION), { status: 200 }); + return new Response(JSON.stringify({ methodResponses: [], sessionState: 's1' }), { status: 200 }); + })); + return urls; + } + + async function connect(serverUrl: string) { + vi.resetModules(); + const { JMAPClient } = await import('@/lib/jmap/client'); + const client = new JMAPClient(serverUrl, 'ent-alice', 'pw'); + await client.connect(); + const session = (client as unknown as { session: typeof SESSION }).session; + client.disconnect(); + return session; + } + + it('sends discovery and every session URL through /__jmap in the desktop build', async () => { + vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1'); + const urls = stubFetch(); + const noteSession = vi.fn(); + setBridge({ version: 1, platform: 'win32', servers: { prepare: vi.fn(), noteSession } }); + const session = await connect('http://192.168.1.5:8080'); + const base = `${window.location.origin}/__jmap/${Buffer.from('http://192.168.1.5:8080').toString('base64url')}`; + expect(urls[0]).toBe(`${base}/.well-known/jmap`); + expect(session.apiUrl).toBe(`${base}/jmap/`); + expect(session.downloadUrl).toBe(`${base}/jmap/download/{accountId}/{blobId}/{name}?accept={type}`); + expect(session.uploadUrl).toBe(`${base}/jmap/upload/{accountId}/`); + expect(session.eventSourceUrl).toBe(`${base}/jmap/eventsource/`); + expect(urls.filter((u) => u.startsWith('http://192.168.1.5'))).toEqual([]); + expect(noteSession).toHaveBeenCalledWith('http://192.168.1.5:8080', expect.objectContaining({ apiUrl: `${base}/jmap/` })); + }); + + it('a server build talks to the server directly, as it always did', async () => { + const urls = stubFetch(); + const session = await connect('http://192.168.1.5:8080'); + expect(urls[0]).toBe('http://192.168.1.5:8080/.well-known/jmap'); + expect(session.apiUrl).toBe('http://192.168.1.5:8080/jmap/'); + }); +}); + +describe('runDesktopBrowserSignIn', () => { + const ENDPOINTS = { authorization_endpoint: 'https://mail.example/authorize', client_id: 'registered-client', scope: 'openid email' }; + + function authBridge(result: unknown) { + const completeBrowserSignIn = vi.fn(async (_flowId: string, url: string) => ( + typeof result === 'function' ? (result as (u: string) => unknown)(url) : result + )); + const bridge = { + version: 1, + platform: 'win32', + servers: { prepare: vi.fn(async () => ({ ok: true, approved: false })), noteSession: vi.fn() }, + auth: { + beginBrowserSignIn: vi.fn(async () => ({ flowId: 'f1', redirectUri: 'http://127.0.0.1:51234/bulwark-desktop/callback' })), + completeBrowserSignIn, + cancelBrowserSignIn: vi.fn(), + }, + } as unknown as BulwarkDesktopBridge; + setBridge(bridge); + return bridge; + } + + const options = { serverUrl: 'https://mail.example', callbackPath: '/en/auth/callback', slot: 1, remember: true, addAccount: false }; + + beforeEach(() => { + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify(ENDPOINTS), { status: 200 }))); + }); + + it('runs PKCE with the loopback redirect and parks what the callback page needs', async () => { + const { runDesktopBrowserSignIn } = await import('../browser-sign-in'); + let authorize = new URL('https://x'); + authBridge((url: string) => { + authorize = new URL(url); + return { ok: true, code: 'the-code', state: authorize.searchParams.get('state') }; + }); + const outcome = await runDesktopBrowserSignIn(options); + expect(outcome.kind).toBe('callback'); + expect(authorize.origin + authorize.pathname).toBe('https://mail.example/authorize'); + expect(authorize.searchParams.get('client_id')).toBe('registered-client'); + expect(authorize.searchParams.get('redirect_uri')).toBe('http://127.0.0.1:51234/bulwark-desktop/callback'); + expect(authorize.searchParams.get('code_challenge_method')).toBe('S256'); + expect(authorize.searchParams.get('code_challenge')).toBeTruthy(); + expect(sessionStorage.getItem('oauth_redirect_uri')).toBe('http://127.0.0.1:51234/bulwark-desktop/callback'); + expect(sessionStorage.getItem('oauth_client_id')).toBe('registered-client'); + expect(sessionStorage.getItem('oauth_server_url')).toBe('https://mail.example'); + expect(sessionStorage.getItem('oauth_cookie_slot')).toBe('1'); + expect(sessionStorage.getItem('oauth_code_verifier')).toBeTruthy(); + }); + + it('rejects an answer with another state', async () => { + const { runDesktopBrowserSignIn } = await import('../browser-sign-in'); + authBridge({ ok: true, code: 'c', state: 'forged' }); + const outcome = await runDesktopBrowserSignIn(options); + expect(outcome).toEqual({ kind: 'failed', error: 'state_mismatch' }); + expect(sessionStorage.getItem('oauth_code_verifier')).toBeNull(); + }); + + it('sends nothing to a server the user declined', async () => { + const { runDesktopBrowserSignIn } = await import('../browser-sign-in'); + const bridge = authBridge({ ok: false, error: 'cancelled' }); + bridge.servers!.prepare = vi.fn(async () => ({ ok: false as const, reason: 'declined' as const })); + const outcome = await runDesktopBrowserSignIn(options); + expect(outcome.kind).toBe('declined'); + expect(bridge.auth!.beginBrowserSignIn).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('says so when the server has no OAuth, and closes the listener', async () => { + const { runDesktopBrowserSignIn } = await import('../browser-sign-in'); + vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 404 }))); + const bridge = authBridge({ ok: true }); + const outcome = await runDesktopBrowserSignIn(options); + expect(outcome.kind).toBe('unavailable'); + expect(bridge.auth!.cancelBrowserSignIn).toHaveBeenCalledWith('f1'); + expect(bridge.auth!.completeBrowserSignIn).not.toHaveBeenCalled(); + }); +}); + +describe('isDesktopBlockedRoute', () => { + it('closes pairing and hand-off, and nothing else', async () => { + const { isDesktopBlockedRoute } = await import('../blocked-routes'); + for (const path of ['/api/auth/pair/create', '/api/auth/pair/token', '/api/auth/handoff/redeem', '/api/auth/impersonate']) { + expect(isDesktopBlockedRoute(path), path).toBe(true); + } + for (const path of ['/api/auth/session', '/api/auth/token', '/api/auth/pairs', '/en/login']) { + expect(isDesktopBlockedRoute(path), path).toBe(false); + } + }); +}); diff --git a/lib/desktop/__tests__/custom-upstream.test.ts b/lib/desktop/__tests__/custom-upstream.test.ts new file mode 100644 index 000000000..b5cd233be --- /dev/null +++ b/lib/desktop/__tests__/custom-upstream.test.ts @@ -0,0 +1,231 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +/** + * A desktop account's refresh token belongs to a server only the app knows. A + * TOTP or browser sign-in has to be able to refresh there, never at a configured + * server, and never with a configured client secret. + */ + +vi.mock('next/server', () => ({ + NextResponse: { + json: (data: unknown, init?: { status?: number }) => ({ + json: async () => data, + status: init?.status ?? 200, + }), + }, +})); + +vi.mock('@/lib/logger', () => ({ + logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() }, +})); + +vi.mock('@/lib/oauth/cookie-config', () => ({ + getCookieOptions: () => ({ path: '/', maxAge: 2592000 }), + getSignInCookieOptions: (remember: boolean) => ({ path: '/', ...(remember ? { maxAge: 2592000 } : {}) }), +})); + +const config: Record = {}; +vi.mock('@/lib/admin/config-manager', () => ({ + configManager: { + ensureLoaded: async () => {}, + get: (key: string, fallback: unknown) => (key in config ? config[key] : fallback), + }, +})); + +vi.mock('@/lib/auth/crypto', () => ({ + encryptPayload: (payload: Record) => JSON.stringify(payload), + decryptPayload: (value: string) => JSON.parse(value), +})); + +const isPublicHttpUrl = vi.hoisted(() => vi.fn(async () => true)); +vi.mock('@/lib/security/url-guard', async (original) => ({ + ...(await original()), + isPublicHttpUrl, +})); + +vi.mock('@/lib/stalwart/auth-context', () => ({ + clearStalwartAuthContextInStore: vi.fn(), + readStalwartAuthContextFromStore: () => null, +})); + +vi.setConfig({ testTimeout: 60_000 }); + +class FakeCookies { + store = new Map(); + get(name: string) { + const value = this.store.get(name); + return value === undefined ? undefined : { name, value }; + } + set(name: string, value: string) { + this.store.set(name, value); + } + delete(arg: string | { name: string }) { + this.store.delete(typeof arg === 'string' ? arg : arg.name); + } +} + +let cookieStore: FakeCookies; +vi.mock('next/headers', () => ({ cookies: async () => cookieStore })); + +const SERVER = 'https://mail.typed.example'; +const CONFIGURED = 'https://configured.example'; + +const metadata = (base: string) => ({ + issuer: base, + authorization_endpoint: `${base}/authorize`, + token_endpoint: `${base}/auth/token`, + revocation_endpoint: `${base}/auth/revoke`, +}); + +function stubUpstream(impl?: (url: string, body: string) => Response) { + const calls: { url: string; body: string }[] = []; + vi.stubGlobal('fetch', vi.fn(async (input: string | URL, init?: RequestInit) => { + const url = String(input); + const base = new URL(url).origin; + if (url.includes('/.well-known/')) return new Response(JSON.stringify(metadata(base)), { status: 200 }); + const body = String(init?.body ?? ''); + calls.push({ url, body }); + return impl ? impl(url, body) : new Response(JSON.stringify({ access_token: 'new-access', expires_in: 3600 }), { status: 200 }); + })); + return calls; +} + +function request(method: string, params: Record = {}, body?: unknown): unknown { + return { + method, + headers: new Headers({ 'sec-fetch-site': 'same-origin' }), + nextUrl: { searchParams: new URLSearchParams(params), basePath: '' }, + json: async () => body, + }; +} + +async function loadRoute() { + vi.resetModules(); + return import('@/app/api/auth/token/route'); +} + +beforeEach(() => { + cookieStore = new FakeCookies(); + for (const key of Object.keys(config)) delete config[key]; + isPublicHttpUrl.mockReset(); + isPublicHttpUrl.mockImplementation(async () => true); +}); + +afterEach(() => { + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); +}); + +describe('PUT /api/auth/token in the desktop build', () => { + beforeEach(() => { + vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1'); + cookieStore.set('jmap_rt', 'refresh-1'); + cookieStore.set('jmap_rtu', JSON.stringify({ s: SERVER, c: 'client-from-register' })); + }); + + it('refreshes at the server of the record, with its client', async () => { + const calls = stubUpstream(); + const { PUT } = await loadRoute(); + const res = (await PUT(request('PUT', { force: 'true' }) as Parameters[0])) as unknown as { status: number }; + expect(res.status).toBe(200); + expect(calls).toHaveLength(1); + expect(calls[0].url).toBe(`${SERVER}/auth/token`); + const sent = new URLSearchParams(calls[0].body); + expect(sent.get('client_id')).toBe('client-from-register'); + expect(sent.get('refresh_token')).toBe('refresh-1'); + }); + + it('goes to the record even with a configured server, and never sends its secret', async () => { + config.jmapServerUrl = CONFIGURED; + config.oauthClientId = 'configured-client'; + config.oauthClientSecret = 'configured-secret'; + const calls = stubUpstream(); + const { PUT } = await loadRoute(); + await PUT(request('PUT', { force: 'true' }) as Parameters[0]); + expect(calls[0].url).toBe(`${SERVER}/auth/token`); + const sent = new URLSearchParams(calls[0].body); + expect(sent.get('client_secret')).toBeNull(); + expect(sent.get('client_id')).toBe('client-from-register'); + }); + + it('answers 503, not 500, when the mail server is unreachable, so the session stays', async () => { + vi.stubGlobal('fetch', vi.fn(async () => { throw new TypeError('fetch failed'); })); + const { PUT } = await loadRoute(); + const res = (await PUT(request('PUT', { force: 'true' }) as Parameters[0])) as unknown as { status: number }; + expect(res.status).toBe(503); + expect(cookieStore.get('jmap_rt')).toBeDefined(); + }); + + it('refuses a record whose address no longer passes the guard', async () => { + isPublicHttpUrl.mockImplementation(async () => false); + const calls = stubUpstream(); + const { PUT } = await loadRoute(); + const res = (await PUT(request('PUT', { force: 'true' }) as Parameters[0])) as unknown as { status: number }; + expect(calls).toHaveLength(0); + expect(res.status).toBe(503); + }); +}); + +describe('POST /api/auth/token in the desktop build', () => { + const body = { + code: 'code-1', + code_verifier: 'verifier', + redirect_uri: 'http://127.0.0.1:51234/bulwark-desktop/callback', + slot: 2, + custom_server_url: SERVER, + client_id: 'client-from-register', + }; + + beforeEach(() => vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1')); + + it('exchanges at the typed server and leaves a record for the refresh', async () => { + const calls = stubUpstream(() => new Response(JSON.stringify({ access_token: 'a', refresh_token: 'r', expires_in: 3600 }), { status: 200 })); + const { POST } = await loadRoute(); + const res = (await POST(request('POST', {}, body) as Parameters[0])) as unknown as { status: number }; + expect(res.status).toBe(200); + expect(calls[0].url).toBe(`${SERVER}/auth/token`); + expect(new URLSearchParams(calls[0].body).get('redirect_uri')).toBe(body.redirect_uri); + expect(JSON.parse(cookieStore.get('jmap_rtu_2')!.value)).toEqual({ s: SERVER, c: 'client-from-register' }); + }); + + it('refuses a private address the user did not approve', async () => { + isPublicHttpUrl.mockImplementation(async () => false); + const calls = stubUpstream(); + const { POST } = await loadRoute(); + const res = (await POST(request('POST', {}, { ...body, custom_server_url: 'http://10.0.0.1' }) as Parameters[0])) as unknown as { status: number }; + expect(res.status).toBe(400); + expect(calls).toHaveLength(0); + }); + + it('sign-out revokes at the record and removes it', async () => { + cookieStore.set('jmap_rt', 'refresh-1'); + cookieStore.set('jmap_rtu', JSON.stringify({ s: SERVER, c: 'client-from-register' })); + const calls = stubUpstream(); + const { DELETE } = await loadRoute(); + await DELETE(request('DELETE') as Parameters[0]); + expect(calls.map((c) => c.url)).toEqual([`${SERVER}/auth/revoke`]); + expect(cookieStore.get('jmap_rtu')).toBeUndefined(); + expect(cookieStore.get('jmap_rt')).toBeUndefined(); + }); +}); + +describe('in a server build', () => { + it('ignores a planted record and a custom_server_url', async () => { + config.jmapServerUrl = CONFIGURED; + config.oauthClientId = 'configured-client'; + cookieStore.set('jmap_rt', 'refresh-1'); + cookieStore.set('jmap_rtu', JSON.stringify({ s: SERVER, c: 'client-from-register' })); + const calls = stubUpstream(); + const { PUT } = await loadRoute(); + await PUT(request('PUT', { force: 'true' }) as Parameters[0]); + expect(calls[0].url).toBe(`${CONFIGURED}/auth/token`); + }); + + it('still answers 500 on an unexpected failure', async () => { + cookieStore.set('jmap_rt', 'refresh-1'); + stubUpstream(); + const { PUT } = await loadRoute(); + const res = (await PUT(request('PUT', { force: 'true' }) as Parameters[0])) as unknown as { status: number }; + expect(res.status).toBe(500); + }); +}); diff --git a/lib/desktop/browser-sign-in.ts b/lib/desktop/browser-sign-in.ts new file mode 100644 index 000000000..a48437771 --- /dev/null +++ b/lib/desktop/browser-sign-in.ts @@ -0,0 +1,104 @@ +/** + * Sign-in through the system browser in the desktop build, for any Stalwart. + * + * A window on app.bulwark.localhost can't be a redirect target: Stalwart 1.0 + * refuses it for a public client, and identity providers won't embed. So the + * Electron main process listens on http://127.0.0.1: for one redirect and + * hands the code back. The rest is the regular PKCE flow: the same + * sessionStorage keys, then the callback page, which reads the stored redirect + * URI because it can't be rebuilt from this page's origin. + * + * Google and Entra can't work here. Their clients are registered by whoever + * owns them, and in standalone mode nobody does. Those need an instance. + */ + +import { apiFetch } from '@/lib/browser-navigation'; +import { generateCodeChallenge, generateCodeVerifier, generateState } from '@/lib/oauth/pkce'; +import { getDesktopBridge } from './bridge'; + +export const DESKTOP_REDIRECT_URI_KEY = 'oauth_redirect_uri'; +export const DESKTOP_CLIENT_ID_KEY = 'oauth_client_id'; + +export type DesktopBrowserSignInOutcome = + | { kind: 'callback'; callbackUrl: string } + | { kind: 'unavailable' } + | { kind: 'declined' } + | { kind: 'cancelled' } + | { kind: 'failed'; error: string }; + +interface DesktopOAuthAnswer { + authorization_endpoint: string; + client_id: string; + scope: string; +} + +export function browserSignInAvailable(): boolean { + return !!getDesktopBridge()?.auth; +} + +export async function runDesktopBrowserSignIn(options: { + serverUrl: string; + callbackPath: string; + slot: number; + remember: boolean; + addAccount: boolean; +}): Promise { + const bridge = getDesktopBridge(); + if (!bridge?.auth) return { kind: 'unavailable' }; + const { serverUrl, callbackPath, slot, remember, addAccount } = options; + + const prepared = await bridge.servers?.prepare(serverUrl); + if (prepared && !prepared.ok) return { kind: 'declined' }; + + const { flowId, redirectUri } = await bridge.auth.beginBrowserSignIn(); + try { + const res = await apiFetch('/api/auth/desktop-oauth', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ serverUrl, redirectUri }), + }); + if (!res.ok) { + bridge.auth.cancelBrowserSignIn(flowId); + return { kind: 'unavailable' }; + } + const answer = (await res.json()) as DesktopOAuthAnswer; + + const verifier = generateCodeVerifier(); + const challenge = await generateCodeChallenge(verifier); + const state = generateState(); + + const authorizeUrl = new URL(answer.authorization_endpoint); + authorizeUrl.searchParams.set('response_type', 'code'); + authorizeUrl.searchParams.set('client_id', answer.client_id); + authorizeUrl.searchParams.set('redirect_uri', redirectUri); + authorizeUrl.searchParams.set('scope', answer.scope); + authorizeUrl.searchParams.set('state', state); + authorizeUrl.searchParams.set('code_challenge', challenge); + authorizeUrl.searchParams.set('code_challenge_method', 'S256'); + if (addAccount) authorizeUrl.searchParams.set('prompt', 'select_account'); + + const result = await bridge.auth.completeBrowserSignIn(flowId, authorizeUrl.toString()); + if (!result.ok) { + return result.error === 'cancelled' ? { kind: 'cancelled' } : { kind: 'failed', error: result.error }; + } + if (result.state !== state) return { kind: 'failed', error: 'state_mismatch' }; + + // What the callback page expects from a same-window flow, plus the two + // values only this flow has. + sessionStorage.setItem('oauth_code_verifier', verifier); + sessionStorage.setItem('oauth_state', state); + sessionStorage.setItem('oauth_server_url', serverUrl); + sessionStorage.removeItem('oauth_server_id'); + sessionStorage.setItem('oauth_remember', String(remember)); + sessionStorage.setItem('oauth_cookie_slot', String(slot)); + sessionStorage.setItem(DESKTOP_REDIRECT_URI_KEY, redirectUri); + sessionStorage.setItem(DESKTOP_CLIENT_ID_KEY, answer.client_id); + if (addAccount) sessionStorage.setItem('oauth_add_account_mode', 'true'); + + const query = new URLSearchParams({ code: result.code, state: result.state }); + return { kind: 'callback', callbackUrl: `${callbackPath}?${query}` }; + } catch (error) { + bridge.auth.cancelBrowserSignIn(flowId); + return { kind: 'failed', error: error instanceof Error ? error.message : String(error) }; + } +} diff --git a/lib/oauth/custom-upstream.ts b/lib/oauth/custom-upstream.ts new file mode 100644 index 000000000..c8230f36f --- /dev/null +++ b/lib/oauth/custom-upstream.ts @@ -0,0 +1,72 @@ +/** + * Which server minted a slot's refresh token, for the desktop build. + * + * The token routes know a server only through `jmap_rts`, the id of an entry in + * `jmapServers`, and otherwise fall back to the configured server. A standalone + * desktop app has neither: every account is a server the user typed. So refresh, + * revocation and sign-out would throw "OAuth misconfigured", and the account + * would first look unreachable and then be signed out. + * + * The record is sealed so only this server can have written it. Whether the + * address may be reached isn't stored: it is checked again on every read, by + * the same rule as the sign-in routes. + */ + +import type { cookies } from 'next/headers'; +import { configManager } from '@/lib/admin/config-manager'; +import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers'; +import { decryptPayload, encryptPayload } from '@/lib/auth/crypto'; +import { getSignInCookieOptions } from '@/lib/oauth/cookie-config'; +import { isPublicHttpUrl } from '@/lib/security/url-guard'; + +type CookieStore = Awaited>; + +const COOKIE = 'jmap_rtu'; + +export interface CustomUpstream { + serverUrl: string; + clientId: string; + /** The user approved this server, so it may be on a private address. */ + trusted: boolean; +} + +export function customUpstreamCookieName(slot: number): string { + return slot === 0 ? COOKIE : `${COOKIE}_${slot}`; +} + +/** Null when the address isn't one the sign-in routes would accept right now. */ +export async function resolveCustomUpstream(serverUrl: unknown, clientId: unknown): Promise { + if (typeof serverUrl !== 'string' || !serverUrl || typeof clientId !== 'string' || !clientId) return null; + await configManager.ensureLoaded(); + const servers = parseJmapServers(configManager.get('jmapServers', [])); + const trusted = resolveTrustedJmapUrl(serverUrl, configManager.get('jmapServerUrl', ''), servers) !== null; + if (!trusted && !(await isPublicHttpUrl(serverUrl))) return null; + return { serverUrl: serverUrl.replace(/\/+$/, ''), clientId, trusted }; +} + +export function writeCustomUpstream( + cookieStore: CookieStore, + slot: number, + upstream: Pick, + remember: boolean, +): void { + const value = encryptPayload({ s: upstream.serverUrl, c: upstream.clientId }, 'custom-upstream'); + cookieStore.set(customUpstreamCookieName(slot), value, getSignInCookieOptions(remember)); +} + +export async function readCustomUpstream(cookieStore: CookieStore, slot: number): Promise { + const value = cookieStore.get(customUpstreamCookieName(slot))?.value; + if (!value) return null; + let payload: Record | null; + try { + payload = decryptPayload(value, 'custom-upstream'); + } catch { + return null; + } + return payload ? resolveCustomUpstream(payload.s, payload.c) : null; +} + +export function clearCustomUpstream(cookieStore: CookieStore, slot: number): void { + const name = customUpstreamCookieName(slot); + if (cookieStore.get(name)) cookieStore.delete(name); +} diff --git a/lib/oauth/token-exchange.ts b/lib/oauth/token-exchange.ts index a60031595..6a8e2437e 100644 --- a/lib/oauth/token-exchange.ts +++ b/lib/oauth/token-exchange.ts @@ -8,6 +8,7 @@ import { configManager } from '@/lib/admin/config-manager'; import { parseJmapServers, findServerById } from '@/lib/admin/jmap-servers'; import { stalwartDefaultClientId } from '@/lib/auth/stalwart-password-login'; import { getOauthResource } from '@/lib/oauth/tokens'; +import type { CustomUpstream } from '@/lib/oauth/custom-upstream'; // Fallback OAuth client id used when no client is configured. The password+TOTP // login route mints tokens against the mail server's built-in OAuth with this @@ -25,6 +26,12 @@ export interface ClientConfigOptions { * so a misconfiguration surfaces at login rather than as a broken session. */ fallbackClientId?: string; + /** + * The server this token belongs to when it's in no configured list: the + * desktop app's accounts (lib/oauth/custom-upstream.ts). Wins over `serverId` + * and the global server, and the configured client secret is never sent to it. + */ + upstream?: CustomUpstream | null; } function httpOrigin(url: string): string | null { @@ -101,6 +108,10 @@ function discoveryBase(url: string): string { } export function getRequiredConfig(serverId?: string | null, options?: ClientConfigOptions) { + if (options?.upstream) { + const { clientId, serverUrl } = options.upstream; + return { clientId, serverUrl, discoveryUrl: discoveryBase(serverUrl), serverId: undefined, endSessionUrl: undefined, resource: undefined }; + } const entry = getServerEntry(serverId); const globalClientId = configManager.get('oauthClientId', '') || process.env.OAUTH_CLIENT_ID; @@ -139,7 +150,8 @@ export function getRequiredConfig(serverId?: string | null, options?: ClientConf * with a client it registers itself (lib/auth/stalwart-password-login.ts), * whose id the refresh must present instead of the default one. */ -export async function defaultClientOptions(serverId?: string | null): Promise { +export async function defaultClientOptions(serverId?: string | null, upstream?: CustomUpstream | null): Promise { + if (upstream) return { upstream }; const { clientId, serverUrl } = getRequiredConfig(serverId, { fallbackClientId: DEFAULT_CLIENT_ID }); if (clientId !== DEFAULT_CLIENT_ID) return { fallbackClientId: DEFAULT_CLIENT_ID }; return { fallbackClientId: await stalwartDefaultClientId(serverUrl, DEFAULT_CLIENT_ID, true) }; @@ -177,7 +189,7 @@ export async function getMetadata(serverId?: string | null, options?: ClientConf export function buildOAuthParams(base: Record, serverId?: string | null, options?: ClientConfigOptions): URLSearchParams { const { clientId } = getRequiredConfig(serverId, options); const params = new URLSearchParams({ ...base, client_id: clientId }); - const secret = getClientSecret(serverId); + const secret = options?.upstream ? '' : getClientSecret(serverId); if (secret) { params.set('client_secret', secret); } @@ -197,15 +209,16 @@ export async function exchangeCodeForTokens( codeVerifier: string, redirectUri: string, serverId?: string | null, + options?: ClientConfigOptions, ): Promise { - const tokenEndpoint = await getTokenEndpoint(serverId); + const tokenEndpoint = await getTokenEndpoint(serverId, options); const params = buildOAuthParams({ grant_type: 'authorization_code', code, redirect_uri: redirectUri, code_verifier: codeVerifier, - }, serverId); + }, serverId, options); // The body carries the code, the PKCE verifier and the client secret. The // endpoint was validated; a redirect would re-send all of it to a host diff --git a/locales/ar/common.json b/locales/ar/common.json index cc8420d14..6054e416c 100644 --- a/locales/ar/common.json +++ b/locales/ar/common.json @@ -6451,5 +6451,14 @@ "next_issue": "الخطأ الإملائي أو النحوي التالي", "previous_issue": "الخطأ الإملائي أو النحوي السابق" } + }, + "desktop_sign_in": { + "browser_button": "سجّل الدخول باستخدام المتصفح", + "browser_waiting": "أكمل تسجيل الدخول في المتصفح", + "browser_unavailable": "لا يتيح هذا الخادم تسجيل الدخول عبر المتصفح. استخدم كلمة المرور بدلًا من ذلك.", + "browser_failed": "لم ينجح تسجيل الدخول عبر المتصفح. حاول مرة أخرى.", + "server_needed": "أدخل عنوان الخادم أولًا.", + "server_declined": "لم تسمح بهذا الخادم، لذا لم يُرسل إليه أي شيء.", + "server_invalid": "عنوان الخادم هذا غير صالح." } } diff --git a/locales/ca/common.json b/locales/ca/common.json index 6e15f53fc..bab8e3df8 100644 --- a/locales/ca/common.json +++ b/locales/ca/common.json @@ -6451,5 +6451,14 @@ "next_issue": "Error d'ortografia o de gramàtica següent", "previous_issue": "Error d'ortografia o de gramàtica anterior" } + }, + "desktop_sign_in": { + "browser_button": "Inicia la sessió amb el navegador", + "browser_waiting": "Acaba d'iniciar la sessió al navegador", + "browser_unavailable": "Aquest servidor no permet iniciar la sessió amb el navegador. Fes servir la contrasenya.", + "browser_failed": "No s'ha pogut iniciar la sessió amb el navegador. Torna-ho a provar.", + "server_needed": "Primer escriu l'adreça del servidor.", + "server_declined": "No has permès aquest servidor, així que no s'hi ha enviat res.", + "server_invalid": "Aquesta adreça de servidor no és vàlida." } } diff --git a/locales/cs/common.json b/locales/cs/common.json index d8426dce2..340f13245 100644 --- a/locales/cs/common.json +++ b/locales/cs/common.json @@ -6451,5 +6451,14 @@ "next_issue": "Další pravopisná nebo gramatická chyba", "previous_issue": "Předchozí pravopisná nebo gramatická chyba" } + }, + "desktop_sign_in": { + "browser_button": "Přihlásit se v prohlížeči", + "browser_waiting": "Dokončete přihlášení v prohlížeči", + "browser_unavailable": "Tento server přihlášení přes prohlížeč nenabízí. Použijte heslo.", + "browser_failed": "Přihlášení přes prohlížeč se nezdařilo. Zkuste to znovu.", + "server_needed": "Nejdřív zadejte adresu serveru.", + "server_declined": "Tento server jste nepovolili, proto se na něj nic neodeslalo.", + "server_invalid": "Tato adresa serveru není platná." } } diff --git a/locales/da/common.json b/locales/da/common.json index cf66a7294..004e7808f 100644 --- a/locales/da/common.json +++ b/locales/da/common.json @@ -6451,5 +6451,14 @@ "next_issue": "Næste stave- eller grammatikfejl", "previous_issue": "Forrige stave- eller grammatikfejl" } + }, + "desktop_sign_in": { + "browser_button": "Log ind med din browser", + "browser_waiting": "Gør login færdigt i din browser", + "browser_unavailable": "Denne server tilbyder ikke login via browser. Brug din adgangskode i stedet.", + "browser_failed": "Login via browseren lykkedes ikke. Prøv igen.", + "server_needed": "Angiv først serverens adresse.", + "server_declined": "Du tillod ikke denne server, så der blev ikke sendt noget til den.", + "server_invalid": "Denne serveradresse er ikke gyldig." } } diff --git a/locales/de/common.json b/locales/de/common.json index 3772dbab8..29a0c852c 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -6451,5 +6451,14 @@ "next_issue": "Nächster Rechtschreib- oder Grammatikfehler", "previous_issue": "Vorheriger Rechtschreib- oder Grammatikfehler" } + }, + "desktop_sign_in": { + "browser_button": "Mit dem Browser anmelden", + "browser_waiting": "Schließen Sie die Anmeldung im Browser ab", + "browser_unavailable": "Dieser Server bietet keine Anmeldung über den Browser an. Verwenden Sie stattdessen Ihr Passwort.", + "browser_failed": "Die Anmeldung über den Browser hat nicht geklappt. Versuchen Sie es noch einmal.", + "server_needed": "Geben Sie zuerst die Adresse Ihres Servers ein.", + "server_declined": "Sie haben diesen Server nicht zugelassen, daher wurde nichts an ihn gesendet.", + "server_invalid": "Diese Serveradresse ist ungültig." } } diff --git a/locales/en/common.json b/locales/en/common.json index 57a58dab4..6318dea48 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -6451,5 +6451,14 @@ "next_issue": "Next spelling or grammar issue", "previous_issue": "Previous spelling or grammar issue" } + }, + "desktop_sign_in": { + "browser_button": "Sign in with your browser", + "browser_waiting": "Finish signing in in your browser", + "browser_unavailable": "This server doesn't offer sign-in through a browser. Use your password instead.", + "browser_failed": "Signing in through your browser didn't work. Try again.", + "server_needed": "Enter your server's address first.", + "server_declined": "You didn't allow this server, so nothing was sent to it.", + "server_invalid": "This server address isn't valid." } } diff --git a/locales/es/common.json b/locales/es/common.json index cc786a268..e113918a6 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -6451,5 +6451,14 @@ "next_issue": "Siguiente error de ortografía o gramática", "previous_issue": "Anterior error de ortografía o gramática" } + }, + "desktop_sign_in": { + "browser_button": "Iniciar sesión con el navegador", + "browser_waiting": "Termina de iniciar sesión en el navegador", + "browser_unavailable": "Este servidor no permite iniciar sesión con el navegador. Usa tu contraseña.", + "browser_failed": "No se pudo iniciar sesión con el navegador. Inténtalo de nuevo.", + "server_needed": "Primero escribe la dirección del servidor.", + "server_declined": "No permitiste este servidor, así que no se le envió nada.", + "server_invalid": "Esta dirección de servidor no es válida." } } diff --git a/locales/et/common.json b/locales/et/common.json index 2743f7f83..999f74069 100644 --- a/locales/et/common.json +++ b/locales/et/common.json @@ -6451,5 +6451,14 @@ "next_issue": "Järgmine õigekirja- või grammatikaviga", "previous_issue": "Eelmine õigekirja- või grammatikaviga" } + }, + "desktop_sign_in": { + "browser_button": "Logi sisse brauseriga", + "browser_waiting": "Lõpeta sisselogimine brauseris", + "browser_unavailable": "See server ei paku brauseri kaudu sisselogimist. Kasuta parooli.", + "browser_failed": "Brauseri kaudu sisselogimine ei õnnestunud. Proovi uuesti.", + "server_needed": "Sisesta kõigepealt serveri aadress.", + "server_declined": "Sa ei lubanud seda serverit, nii et sellele ei saadetud midagi.", + "server_invalid": "See serveri aadress ei kehti." } } diff --git a/locales/fa/common.json b/locales/fa/common.json index 539f81c1a..7ca19098b 100644 --- a/locales/fa/common.json +++ b/locales/fa/common.json @@ -6451,5 +6451,14 @@ "next_issue": "خطای املایی یا دستوری بعدی", "previous_issue": "خطای املایی یا دستوری قبلی" } + }, + "desktop_sign_in": { + "browser_button": "ورود با مرورگر", + "browser_waiting": "ورود را در مرورگر کامل کنید", + "browser_unavailable": "این سرور ورود از طریق مرورگر را ارائه نمی‌دهد. از گذرواژه خود استفاده کنید.", + "browser_failed": "ورود از طریق مرورگر انجام نشد. دوباره تلاش کنید.", + "server_needed": "ابتدا نشانی سرور را وارد کنید.", + "server_declined": "شما این سرور را مجاز نکردید، پس چیزی به آن فرستاده نشد.", + "server_invalid": "این نشانی سرور معتبر نیست." } } diff --git a/locales/fr/common.json b/locales/fr/common.json index fb7d148ec..2ae64e779 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -6451,5 +6451,14 @@ "next_issue": "Faute d'orthographe ou de grammaire suivante", "previous_issue": "Faute d'orthographe ou de grammaire précédente" } + }, + "desktop_sign_in": { + "browser_button": "Se connecter avec le navigateur", + "browser_waiting": "Terminez la connexion dans votre navigateur", + "browser_unavailable": "Ce serveur ne propose pas la connexion par navigateur. Utilisez votre mot de passe.", + "browser_failed": "La connexion par navigateur a échoué. Réessayez.", + "server_needed": "Saisissez d'abord l'adresse du serveur.", + "server_declined": "Vous n'avez pas autorisé ce serveur, donc rien ne lui a été envoyé.", + "server_invalid": "Cette adresse de serveur n'est pas valide." } } diff --git a/locales/he/common.json b/locales/he/common.json index 73d75426a..3065831d9 100644 --- a/locales/he/common.json +++ b/locales/he/common.json @@ -6452,5 +6452,14 @@ "next_issue": "שגיאת האיות או הדקדוק הבאה", "previous_issue": "שגיאת האיות או הדקדוק הקודמת" } + }, + "desktop_sign_in": { + "browser_button": "כניסה באמצעות הדפדפן", + "browser_waiting": "יש להשלים את הכניסה בדפדפן", + "browser_unavailable": "השרת הזה לא מאפשר כניסה דרך דפדפן. יש להשתמש בסיסמה.", + "browser_failed": "הכניסה דרך הדפדפן לא הצליחה. כדאי לנסות שוב.", + "server_needed": "קודם צריך להזין את כתובת השרת.", + "server_declined": "לא אישרת את השרת הזה, לכן לא נשלח אליו דבר.", + "server_invalid": "כתובת השרת הזו לא תקינה." } } diff --git a/locales/hu/common.json b/locales/hu/common.json index b45fe495e..c208abd21 100644 --- a/locales/hu/common.json +++ b/locales/hu/common.json @@ -6451,5 +6451,14 @@ "next_issue": "Következő helyesírási vagy nyelvtani hiba", "previous_issue": "Előző helyesírási vagy nyelvtani hiba" } + }, + "desktop_sign_in": { + "browser_button": "Bejelentkezés böngészővel", + "browser_waiting": "Fejezze be a bejelentkezést a böngészőben", + "browser_unavailable": "Ez a szerver nem kínál böngészős bejelentkezést. Használja a jelszavát.", + "browser_failed": "A böngészős bejelentkezés nem sikerült. Próbálja újra.", + "server_needed": "Először adja meg a szerver címét.", + "server_declined": "Nem engedélyezte ezt a szervert, ezért semmit sem küldtünk neki.", + "server_invalid": "Ez a szervercím nem érvényes." } } diff --git a/locales/it/common.json b/locales/it/common.json index a021b7397..db903bced 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -6451,5 +6451,14 @@ "next_issue": "Errore di ortografia o grammatica successivo", "previous_issue": "Errore di ortografia o grammatica precedente" } + }, + "desktop_sign_in": { + "browser_button": "Accedi con il browser", + "browser_waiting": "Completa l'accesso nel browser", + "browser_unavailable": "Questo server non offre l'accesso tramite browser. Usa la password.", + "browser_failed": "L'accesso tramite browser non è riuscito. Riprova.", + "server_needed": "Inserisci prima l'indirizzo del server.", + "server_declined": "Non hai autorizzato questo server, quindi non gli è stato inviato nulla.", + "server_invalid": "Questo indirizzo del server non è valido." } } diff --git a/locales/ja/common.json b/locales/ja/common.json index f2ea41909..9bef272e7 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -6451,5 +6451,14 @@ "next_issue": "次のスペルまたは文法の問題", "previous_issue": "前のスペルまたは文法の問題" } + }, + "desktop_sign_in": { + "browser_button": "ブラウザでサインイン", + "browser_waiting": "ブラウザでサインインを完了してください", + "browser_unavailable": "このサーバーはブラウザでのサインインに対応していません。パスワードを使用してください。", + "browser_failed": "ブラウザでのサインインに失敗しました。もう一度お試しください。", + "server_needed": "先にサーバーのアドレスを入力してください。", + "server_declined": "このサーバーを許可しなかったため、何も送信していません。", + "server_invalid": "このサーバーアドレスは正しくありません。" } } diff --git a/locales/ko/common.json b/locales/ko/common.json index cbde91d32..2de81935e 100644 --- a/locales/ko/common.json +++ b/locales/ko/common.json @@ -6451,5 +6451,14 @@ "next_issue": "다음 맞춤법 또는 문법 문제", "previous_issue": "이전 맞춤법 또는 문법 문제" } + }, + "desktop_sign_in": { + "browser_button": "브라우저로 로그인", + "browser_waiting": "브라우저에서 로그인을 마치세요", + "browser_unavailable": "이 서버는 브라우저 로그인을 지원하지 않습니다. 비밀번호를 사용하세요.", + "browser_failed": "브라우저 로그인에 실패했습니다. 다시 시도하세요.", + "server_needed": "먼저 서버 주소를 입력하세요.", + "server_declined": "이 서버를 허용하지 않아 아무것도 보내지 않았습니다.", + "server_invalid": "이 서버 주소는 올바르지 않습니다." } } diff --git a/locales/lv/common.json b/locales/lv/common.json index 02d30c2f9..131e235fe 100644 --- a/locales/lv/common.json +++ b/locales/lv/common.json @@ -6451,5 +6451,14 @@ "next_issue": "Nākamā pareizrakstības vai gramatikas kļūda", "previous_issue": "Iepriekšējā pareizrakstības vai gramatikas kļūda" } + }, + "desktop_sign_in": { + "browser_button": "Pierakstīties ar pārlūku", + "browser_waiting": "Pabeidziet pierakstīšanos pārlūkā", + "browser_unavailable": "Šis serveris nepiedāvā pierakstīšanos ar pārlūku. Izmantojiet paroli.", + "browser_failed": "Pierakstīšanās ar pārlūku neizdevās. Mēģiniet vēlreiz.", + "server_needed": "Vispirms ievadiet servera adresi.", + "server_declined": "Jūs neatļāvāt šo serveri, tāpēc tam nekas netika nosūtīts.", + "server_invalid": "Šī servera adrese nav derīga." } } diff --git a/locales/mn/common.json b/locales/mn/common.json index 4dd86dea2..ae86e31d8 100644 --- a/locales/mn/common.json +++ b/locales/mn/common.json @@ -6451,5 +6451,14 @@ "next_issue": "Дараагийн зөв бичих эсвэл хэл зүйн алдаа", "previous_issue": "Өмнөх зөв бичих эсвэл хэл зүйн алдаа" } + }, + "desktop_sign_in": { + "browser_button": "Хөтчөөр нэвтрэх", + "browser_waiting": "Нэвтрэлтээ хөтөч дээр дуусгана уу", + "browser_unavailable": "Энэ сервер хөтчөөр нэвтрэх боломжгүй. Нууц үгээ ашиглана уу.", + "browser_failed": "Хөтчөөр нэвтэрч чадсангүй. Дахин оролдоно уу.", + "server_needed": "Эхлээд серверийн хаягаа оруулна уу.", + "server_declined": "Та энэ серверийг зөвшөөрөөгүй тул юу ч илгээгээгүй.", + "server_invalid": "Энэ серверийн хаяг буруу байна." } } diff --git a/locales/nb/common.json b/locales/nb/common.json index d98bd1756..05bdf0a2c 100644 --- a/locales/nb/common.json +++ b/locales/nb/common.json @@ -6451,5 +6451,14 @@ "next_issue": "Neste stave- eller grammatikkfeil", "previous_issue": "Forrige stave- eller grammatikkfeil" } + }, + "desktop_sign_in": { + "browser_button": "Logg på med nettleseren", + "browser_waiting": "Fullfør påloggingen i nettleseren", + "browser_unavailable": "Denne serveren tilbyr ikke pålogging via nettleser. Bruk passordet ditt.", + "browser_failed": "Pålogging via nettleseren mislyktes. Prøv igjen.", + "server_needed": "Skriv inn serveradressen først.", + "server_declined": "Du tillot ikke denne serveren, så ingenting ble sendt til den.", + "server_invalid": "Denne serveradressen er ikke gyldig." } } diff --git a/locales/nl/common.json b/locales/nl/common.json index 4d541f358..f050b7b17 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -6451,5 +6451,14 @@ "next_issue": "Volgende spel- of grammaticafout", "previous_issue": "Vorige spel- of grammaticafout" } + }, + "desktop_sign_in": { + "browser_button": "Inloggen met je browser", + "browser_waiting": "Rond het inloggen af in je browser", + "browser_unavailable": "Deze server biedt geen inloggen via een browser. Gebruik je wachtwoord.", + "browser_failed": "Inloggen via de browser is niet gelukt. Probeer het opnieuw.", + "server_needed": "Vul eerst het adres van de server in.", + "server_declined": "Je hebt deze server niet toegestaan, dus er is niets naartoe gestuurd.", + "server_invalid": "Dit serveradres is niet geldig." } } diff --git a/locales/pl/common.json b/locales/pl/common.json index a6a7cd520..e6dc786f5 100644 --- a/locales/pl/common.json +++ b/locales/pl/common.json @@ -6451,5 +6451,14 @@ "next_issue": "Następny błąd pisowni lub gramatyki", "previous_issue": "Poprzedni błąd pisowni lub gramatyki" } + }, + "desktop_sign_in": { + "browser_button": "Zaloguj się w przeglądarce", + "browser_waiting": "Dokończ logowanie w przeglądarce", + "browser_unavailable": "Ten serwer nie oferuje logowania przez przeglądarkę. Użyj hasła.", + "browser_failed": "Logowanie przez przeglądarkę nie powiodło się. Spróbuj ponownie.", + "server_needed": "Najpierw wpisz adres serwera.", + "server_declined": "Ten serwer nie został dozwolony, więc nic do niego nie wysłano.", + "server_invalid": "Ten adres serwera jest nieprawidłowy." } } diff --git a/locales/pt/common.json b/locales/pt/common.json index 685a98aea..512714e8a 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -6451,5 +6451,14 @@ "next_issue": "Próximo erro de ortografia ou gramática", "previous_issue": "Erro de ortografia ou gramática anterior" } + }, + "desktop_sign_in": { + "browser_button": "Entrar com o navegador", + "browser_waiting": "Termine de entrar no navegador", + "browser_unavailable": "Este servidor não oferece entrada pelo navegador. Use sua senha.", + "browser_failed": "Não foi possível entrar pelo navegador. Tente novamente.", + "server_needed": "Digite primeiro o endereço do servidor.", + "server_declined": "Você não permitiu este servidor, então nada foi enviado a ele.", + "server_invalid": "Este endereço de servidor não é válido." } } diff --git a/locales/ro/common.json b/locales/ro/common.json index 45c3fefed..85a94ec62 100644 --- a/locales/ro/common.json +++ b/locales/ro/common.json @@ -6451,5 +6451,14 @@ "next_issue": "Următoarea greșeală de ortografie sau gramatică", "previous_issue": "Greșeala de ortografie sau gramatică anterioară" } + }, + "desktop_sign_in": { + "browser_button": "Conectează-te cu browserul", + "browser_waiting": "Finalizează conectarea în browser", + "browser_unavailable": "Acest server nu oferă conectare prin browser. Folosește parola.", + "browser_failed": "Conectarea prin browser nu a reușit. Încearcă din nou.", + "server_needed": "Introdu mai întâi adresa serverului.", + "server_declined": "Nu ai permis acest server, așa că nu i s-a trimis nimic.", + "server_invalid": "Această adresă de server nu este validă." } } diff --git a/locales/ru/common.json b/locales/ru/common.json index b40069b61..d3e1b6867 100644 --- a/locales/ru/common.json +++ b/locales/ru/common.json @@ -6451,5 +6451,14 @@ "next_issue": "Следующая орфографическая или грамматическая ошибка", "previous_issue": "Предыдущая орфографическая или грамматическая ошибка" } + }, + "desktop_sign_in": { + "browser_button": "Войти через браузер", + "browser_waiting": "Завершите вход в браузере", + "browser_unavailable": "Этот сервер не поддерживает вход через браузер. Используйте пароль.", + "browser_failed": "Не удалось войти через браузер. Попробуйте ещё раз.", + "server_needed": "Сначала введите адрес сервера.", + "server_declined": "Вы не разрешили этот сервер, поэтому ему ничего не отправлено.", + "server_invalid": "Этот адрес сервера недействителен." } } diff --git a/locales/sk/common.json b/locales/sk/common.json index aa0304452..b1f985553 100644 --- a/locales/sk/common.json +++ b/locales/sk/common.json @@ -6451,5 +6451,14 @@ "next_issue": "Ďalšia pravopisná alebo gramatická chyba", "previous_issue": "Predchádzajúca pravopisná alebo gramatická chyba" } + }, + "desktop_sign_in": { + "browser_button": "Prihlásiť sa v prehliadači", + "browser_waiting": "Dokončite prihlásenie v prehliadači", + "browser_unavailable": "Tento server neponúka prihlásenie cez prehliadač. Použite heslo.", + "browser_failed": "Prihlásenie cez prehliadač sa nepodarilo. Skúste to znova.", + "server_needed": "Najprv zadajte adresu servera.", + "server_declined": "Tento server ste nepovolili, preto sa naň nič neodoslalo.", + "server_invalid": "Táto adresa servera nie je platná." } } diff --git a/locales/th/common.json b/locales/th/common.json index 4d2c52754..3257cdf79 100644 --- a/locales/th/common.json +++ b/locales/th/common.json @@ -6459,5 +6459,14 @@ "next_issue": "ปัญหาการสะกดหรือไวยากรณ์ถัดไป", "previous_issue": "ปัญหาการสะกดหรือไวยากรณ์ก่อนหน้า" } + }, + "desktop_sign_in": { + "browser_button": "ลงชื่อเข้าใช้ด้วยเบราว์เซอร์", + "browser_waiting": "ลงชื่อเข้าใช้ให้เสร็จในเบราว์เซอร์", + "browser_unavailable": "เซิร์ฟเวอร์นี้ไม่รองรับการลงชื่อเข้าใช้ผ่านเบราว์เซอร์ โปรดใช้รหัสผ่าน", + "browser_failed": "ลงชื่อเข้าใช้ผ่านเบราว์เซอร์ไม่สำเร็จ โปรดลองอีกครั้ง", + "server_needed": "ป้อนที่อยู่เซิร์ฟเวอร์ก่อน", + "server_declined": "คุณไม่ได้อนุญาตเซิร์ฟเวอร์นี้ จึงไม่มีการส่งข้อมูลใดไป", + "server_invalid": "ที่อยู่เซิร์ฟเวอร์นี้ไม่ถูกต้อง" } } diff --git a/locales/tr/common.json b/locales/tr/common.json index 4f25997fa..4951f5d4e 100644 --- a/locales/tr/common.json +++ b/locales/tr/common.json @@ -6451,5 +6451,14 @@ "next_issue": "Sonraki yazım veya dil bilgisi hatası", "previous_issue": "Önceki yazım veya dil bilgisi hatası" } + }, + "desktop_sign_in": { + "browser_button": "Tarayıcıyla oturum aç", + "browser_waiting": "Oturum açmayı tarayıcınızda tamamlayın", + "browser_unavailable": "Bu sunucu tarayıcıyla oturum açmayı desteklemiyor. Parolanızı kullanın.", + "browser_failed": "Tarayıcıyla oturum açılamadı. Yeniden deneyin.", + "server_needed": "Önce sunucu adresini girin.", + "server_declined": "Bu sunucuya izin vermediniz, bu yüzden ona hiçbir şey gönderilmedi.", + "server_invalid": "Bu sunucu adresi geçerli değil." } } diff --git a/locales/uk/common.json b/locales/uk/common.json index 408e920da..31a8a487c 100644 --- a/locales/uk/common.json +++ b/locales/uk/common.json @@ -6451,5 +6451,14 @@ "next_issue": "Наступна орфографічна або граматична помилка", "previous_issue": "Попередня орфографічна або граматична помилка" } + }, + "desktop_sign_in": { + "browser_button": "Увійти через браузер", + "browser_waiting": "Завершіть вхід у браузері", + "browser_unavailable": "Цей сервер не підтримує вхід через браузер. Використайте пароль.", + "browser_failed": "Не вдалося увійти через браузер. Спробуйте ще раз.", + "server_needed": "Спочатку введіть адресу сервера.", + "server_declined": "Ви не дозволили цей сервер, тому йому нічого не надіслано.", + "server_invalid": "Ця адреса сервера недійсна." } } diff --git a/locales/vi/common.json b/locales/vi/common.json index 6952aec2e..80ed5998e 100644 --- a/locales/vi/common.json +++ b/locales/vi/common.json @@ -6463,5 +6463,14 @@ "next_issue": "Lỗi chính tả hoặc ngữ pháp tiếp theo", "previous_issue": "Lỗi chính tả hoặc ngữ pháp trước đó" } + }, + "desktop_sign_in": { + "browser_button": "Đăng nhập bằng trình duyệt", + "browser_waiting": "Hoàn tất đăng nhập trong trình duyệt", + "browser_unavailable": "Máy chủ này không hỗ trợ đăng nhập qua trình duyệt. Hãy dùng mật khẩu.", + "browser_failed": "Không thể đăng nhập qua trình duyệt. Hãy thử lại.", + "server_needed": "Trước tiên hãy nhập địa chỉ máy chủ.", + "server_declined": "Bạn không cho phép máy chủ này nên không có gì được gửi đến đó.", + "server_invalid": "Địa chỉ máy chủ này không hợp lệ." } } diff --git a/locales/zh-TW/common.json b/locales/zh-TW/common.json index 1236173cf..fc48b4edd 100644 --- a/locales/zh-TW/common.json +++ b/locales/zh-TW/common.json @@ -6451,5 +6451,14 @@ "next_issue": "下一個拼字或文法問題", "previous_issue": "上一個拼字或文法問題" } + }, + "desktop_sign_in": { + "browser_button": "使用瀏覽器登入", + "browser_waiting": "請在瀏覽器中完成登入", + "browser_unavailable": "此伺服器不支援透過瀏覽器登入。請使用密碼。", + "browser_failed": "無法透過瀏覽器登入。請再試一次。", + "server_needed": "請先輸入伺服器位址。", + "server_declined": "你沒有允許此伺服器,因此沒有傳送任何內容給它。", + "server_invalid": "此伺服器位址無效。" } } diff --git a/locales/zh/common.json b/locales/zh/common.json index f51d10c28..fcb0d463c 100644 --- a/locales/zh/common.json +++ b/locales/zh/common.json @@ -6451,5 +6451,14 @@ "next_issue": "下一个拼写或语法问题", "previous_issue": "上一个拼写或语法问题" } + }, + "desktop_sign_in": { + "browser_button": "使用浏览器登录", + "browser_waiting": "请在浏览器中完成登录", + "browser_unavailable": "此服务器不支持通过浏览器登录。请使用密码。", + "browser_failed": "无法通过浏览器登录。请重试。", + "server_needed": "请先输入服务器地址。", + "server_declined": "你没有允许此服务器,因此没有向它发送任何内容。", + "server_invalid": "此服务器地址无效。" } } diff --git a/stores/auth-store.ts b/stores/auth-store.ts index 380379382..58aedb417 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -16,6 +16,9 @@ import { debug } from '@/lib/debug'; import { setServerAuthIssue } from '@/lib/server-auth-status'; import { generateAccountId, MAX_ACCOUNT_SLOTS } from '@/lib/account-utils'; import { replaceWindowLocation, getPathPrefix, getLocaleFromPath, apiFetch } from '@/lib/browser-navigation'; +import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag'; +import { getDesktopBridge } from '@/lib/desktop/bridge'; +import { DESKTOP_CLIENT_ID_KEY } from '@/lib/desktop/browser-sign-in'; import { isEmbedded, notifyParent } from '@/lib/iframe-bridge'; import { snapshotAccount, restoreAccount, clearAllStores, evictAccount, evictAll, forgetAccountRows } from '@/lib/account-state-manager'; import { clearAllPluginStorage, clearPluginStorageForAccount } from '@/lib/plugin-sandbox/storage-scope'; @@ -470,6 +473,8 @@ async function exchangeOAuthCode(params: { slot, ...(serverId ? { server_id: serverId } : {}), remember, + // The desktop app signs in to a server only it knows about. + ...(IS_DESKTOP_BUILD ? { custom_server_url: serverUrl, client_id: sessionStorage.getItem(DESKTOP_CLIENT_ID_KEY) } : {}), }), }).then((res) => noteSessionAnswer(res, slot)); } @@ -1026,6 +1031,14 @@ function finishSignOut(endSessionUrl: string | null, userInitiated: boolean): vo } } if (endSessionUrl) { + // The desktop build signs in through the system browser, so the provider's + // session lives there. The window itself goes straight to the login page. + const desktopShell = IS_DESKTOP_BUILD ? getDesktopBridge()?.shell : undefined; + if (desktopShell) { + desktopShell.openExternal(endSessionUrl); + navigateToLogin(); + return; + } leavingForProviderLogout = true; replaceWindowLocation(endSessionUrl); return; From a6df6c4aac6f781a2cb02fadd3489619df275133 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 06:41:22 +0100 Subject: [PATCH 4/7] docs: name no address in the desktop http route's comment --- lib/desktop/jmap-route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/desktop/jmap-route.ts b/lib/desktop/jmap-route.ts index 491b2b4e3..cac1869cb 100644 --- a/lib/desktop/jmap-route.ts +++ b/lib/desktop/jmap-route.ts @@ -1,7 +1,7 @@ /** * Where the page reaches a plain http mail server in the desktop app. * - * The app's page is https, so Chromium blocks a fetch to http://192.168.x as + * The app's page is https, so Chromium blocks a fetch to a plain http address as * mixed content, whatever the CSP says. The embedded server forwards * `/__jmap//...` to servers the user approved (src/server/jmap-proxy.ts in * the desktop repo), where the id is the base64url of the server's origin. The From 7ac582f80384b95789b88c3f216383ea0ff4ebaa Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Tue, 3 Nov 2026 06:55:22 +0100 Subject: [PATCH 5/7] fix: compile the desktop trust path out of every other build, and fail a build that keeps it --- app/(main)/[locale]/login/page.tsx | 50 ++++++++++++----------- app/api/auth/totp-token-exchange/route.ts | 3 +- lib/__tests__/standalone-guard.test.ts | 7 ++++ lib/admin/jmap-servers.ts | 8 ++-- lib/desktop/__tests__/trust-flag.test.ts | 8 ++++ lib/desktop/types.ts | 5 ++- scripts/desktop-build-flag.mjs | 3 ++ scripts/standalone-guard.mjs | 9 ++-- 8 files changed, 60 insertions(+), 33 deletions(-) diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index d28ae4039..3ac336bd2 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -993,30 +993,34 @@ function LoginPageContent() { }; /** The desktop build: the server's own sign-in page in the system browser. */ + // Spelled out, not IS_DESKTOP_BUILD: only this form keeps the route out of other + // builds' bundles, which Lite's check on API strings relies on. const handleDesktopBrowserSignIn = async () => { - const target = jmapEndpoint.trim(); - if (!target) { - setDesktopNotice(tDesktop("server_needed")); - return; + if (process.env.NEXT_PUBLIC_BULWARK_DESKTOP === '1') { + const target = jmapEndpoint.trim(); + if (!target) { + setDesktopNotice(tDesktop("server_needed")); + return; + } + setDesktopNotice(null); + setDesktopWaiting(true); + const prefix = getPathPrefix(params.locale as string); + const outcome = await runDesktopBrowserSignIn({ + serverUrl: target, + callbackPath: `${prefix}/${params.locale}/auth/callback`, + slot: useAccountStore.getState().getNextCookieSlot(), + remember: persistProviderSession, + addAccount: isAddAccountMode, + }); + if (outcome.kind === "callback") { + window.location.href = outcome.callbackUrl; + return; + } + setDesktopWaiting(false); + if (outcome.kind === "unavailable") setDesktopNotice(tDesktop("browser_unavailable")); + else if (outcome.kind === "declined") setDesktopNotice(tDesktop("server_declined")); + else if (outcome.kind === "failed") setDesktopNotice(tDesktop("browser_failed")); } - setDesktopNotice(null); - setDesktopWaiting(true); - const prefix = getPathPrefix(params.locale as string); - const outcome = await runDesktopBrowserSignIn({ - serverUrl: target, - callbackPath: `${prefix}/${params.locale}/auth/callback`, - slot: useAccountStore.getState().getNextCookieSlot(), - remember: persistProviderSession, - addAccount: isAddAccountMode, - }); - if (outcome.kind === "callback") { - window.location.href = outcome.callbackUrl; - return; - } - setDesktopWaiting(false); - if (outcome.kind === "unavailable") setDesktopNotice(tDesktop("browser_unavailable")); - else if (outcome.kind === "declined") setDesktopNotice(tDesktop("server_declined")); - else if (outcome.kind === "failed") setDesktopNotice(tDesktop("browser_failed")); }; const handleSubmit = async (e: React.FormEvent) => { @@ -1858,7 +1862,7 @@ function LoginPageContent() { )} - {IS_DESKTOP_BUILD && !hasServerList && browserSignInAvailable() && ( + {process.env.NEXT_PUBLIC_BULWARK_DESKTOP === "1" && !hasServerList && browserSignInAvailable() && (