mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-04 08:15:41 +00:00
feat: merge the webmail side of the desktop app
# Conflicts: # lib/desktop/types.ts # locales/ar/common.json # locales/ca/common.json # locales/cs/common.json # locales/da/common.json # locales/de/common.json # locales/en/common.json # locales/es/common.json # locales/et/common.json # locales/fa/common.json # locales/fr/common.json # locales/he/common.json # locales/hu/common.json # locales/it/common.json # locales/ja/common.json # locales/ko/common.json # locales/lv/common.json # locales/mn/common.json # locales/nb/common.json # locales/nl/common.json # locales/pl/common.json # locales/pt/common.json # locales/ro/common.json # locales/ru/common.json # locales/sk/common.json # locales/th/common.json # locales/tr/common.json # locales/uk/common.json # locales/vi/common.json # locales/zh-TW/common.json # locales/zh/common.json # stores/account-store.ts
This commit is contained in:
@@ -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');
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
"use client";
|
||||
|
||||
import { Suspense, useState, useEffect, useRef, useCallback, useId } from "react";
|
||||
import { IS_DESKTOP_BUILD } from "@/lib/desktop/flag";
|
||||
import { forgetDesktopServer, 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<string | null>(null);
|
||||
const [desktopWaiting, setDesktopWaiting] = useState(false);
|
||||
const tSessionLimits = useTranslations("session_limits");
|
||||
const tOffline = useTranslations("offline");
|
||||
const tTheme = useTranslations("settings.appearance.theme");
|
||||
@@ -985,6 +992,38 @@ function LoginPageContent() {
|
||||
window.location.href = authUrl.toString();
|
||||
};
|
||||
|
||||
/** 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 () => {
|
||||
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);
|
||||
forgetDesktopServer(target, "failed");
|
||||
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,9 +1031,21 @@ 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('/');
|
||||
} else if (IS_DESKTOP_BUILD) {
|
||||
forgetDesktopServer(effectiveServerUrl, 'failed');
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1046,6 +1097,8 @@ function LoginPageContent() {
|
||||
} else if (isMobileHandoff) {
|
||||
// Stale payload should never feed into a later retry's redirect.
|
||||
mobileHandoffPayloadRef.current = null;
|
||||
} else if (IS_DESKTOP_BUILD) {
|
||||
forgetDesktopServer(effectiveServerUrl, 'failed');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1814,6 +1867,24 @@ function LoginPageContent() {
|
||||
</>
|
||||
)}
|
||||
|
||||
{process.env.NEXT_PUBLIC_BULWARK_DESKTOP === "1" && !hasServerList && browserSignInAvailable() && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
className="mt-3 w-full h-11 font-medium text-[0.9375rem] rounded-xl border-border/60 hover:bg-muted/50"
|
||||
onClick={handleDesktopBrowserSignIn}
|
||||
disabled={desktopWaiting || isLoading}
|
||||
data-testid="desktop-browser-sign-in"
|
||||
>
|
||||
{desktopWaiting ? <Loader2 className="w-4 h-4 animate-spin me-2" /> : <LogIn className="w-4 h-4 me-2" />}
|
||||
{desktopWaiting ? tDesktop("browser_waiting") : tDesktop("browser_button")}
|
||||
</Button>
|
||||
)}
|
||||
{IS_DESKTOP_BUILD && desktopNotice && (
|
||||
<p role="alert" data-testid="desktop-notice" className="mt-2 text-sm text-warning leading-relaxed">
|
||||
{desktopNotice}
|
||||
</p>
|
||||
)}
|
||||
{((serverOauthEnabled && oauthDiscoveryDone && !oauthMetadata) || liteOAuthFailed) && (
|
||||
<div className="mt-2 p-3 rounded-xl border border-warning/20 bg-warning/5 flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-warning/15 text-warning flex items-center justify-center flex-shrink-0 shadow-sm">
|
||||
|
||||
@@ -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<string> {
|
||||
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 });
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
+35
-11
@@ -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<void> {
|
||||
async function revokeRefreshToken(token: string, serverId: string | null, metadata: OAuthMetadata | null, upstream: CustomUpstream | null): Promise<void> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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,9 @@ export async function POST(request: NextRequest) {
|
||||
resolvedServerId = matchedEntry.id;
|
||||
} else if (configuredServerUrl && !customRequested) {
|
||||
upstreamUrl = configuredServerUrl;
|
||||
// Spelled out as in lib/admin/jmap-servers.ts, so other builds don't hold it.
|
||||
} else if (process.env.NEXT_PUBLIC_BULWARK_DESKTOP === '1' && isDesktopTrustedServer(serverUrl)) {
|
||||
upstreamUrl = serverUrl;
|
||||
} else if (allowCustomEndpoint) {
|
||||
if (!(await isPublicHttpUrl(serverUrl))) {
|
||||
logger.warn('TOTP login: rejected non-public server URL');
|
||||
@@ -193,7 +205,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 +222,7 @@ async function storeAndRespond(
|
||||
slot: number,
|
||||
serverId: string | null,
|
||||
rememberMe: boolean,
|
||||
upstream: { serverUrl: string; clientId: string } | null,
|
||||
): Promise<NextResponse> {
|
||||
const cookieStore = await cookies();
|
||||
// A sign-in: the maximum session length counts from here (lib/auth/session-age.ts).
|
||||
@@ -227,6 +240,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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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() {
|
||||
<SettingsSection id="devices" title={t('title')} description={t('description')}>
|
||||
{!IS_LITE ? (
|
||||
<div className="space-y-6">
|
||||
<LinkDeviceSection />
|
||||
<div className="border-t border-border" />
|
||||
{!IS_DESKTOP_BUILD && (
|
||||
<>
|
||||
<LinkDeviceSection />
|
||||
<div className="border-t border-border" />
|
||||
</>
|
||||
)}
|
||||
<div className="text-sm text-muted-foreground" dangerouslySetInnerHTML={{ __html: sanitizeI18nHtml(t.raw('not_available')) }} />
|
||||
</div>
|
||||
) : (
|
||||
@@ -1610,7 +1615,7 @@ export function AccountSecuritySettings() {
|
||||
description={withoutPassword ? t('description') : t('sections.devices_description')}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{!IS_LITE && (
|
||||
{!IS_LITE && !IS_DESKTOP_BUILD && (
|
||||
<>
|
||||
<LinkDeviceSection />
|
||||
<div className="border-t border-border" />
|
||||
|
||||
@@ -31,6 +31,53 @@ 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('fails a server build that still reads the flag at runtime', () => {
|
||||
const dir = tempDir();
|
||||
put(dir, 'server.js');
|
||||
put(dir, '.next/server/chunks/9.js', 'let a="1"===process.env.NEXT_PUBLIC_BULWARK_DESKTOP');
|
||||
expect(guardStandalone(dir, { desktopBuild: false })).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('passes a server build whose server.js holds the config, with the name as a JSON key', () => {
|
||||
const dir = tempDir();
|
||||
put(dir, 'server.js', 'const nextConfig = {"env":{"NEXT_PUBLIC_BULWARK_LITE":"","NEXT_PUBLIC_BULWARK_DESKTOP":""}}');
|
||||
expect(guardStandalone(dir, { desktopBuild: false })).toEqual([]);
|
||||
});
|
||||
|
||||
it('fails the bracket form of the read too', () => {
|
||||
const dir = tempDir();
|
||||
put(dir, 'server.js');
|
||||
put(dir, '.next/server/chunks/9.js', 'let a=process.env["NEXT_PUBLIC_BULWARK_DESKTOP"]');
|
||||
expect(guardStandalone(dir, { desktopBuild: false })).toHaveLength(1);
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
/**
|
||||
* Multi-server JMAP support: schema, parsing, lookup, and redaction helpers.
|
||||
*/
|
||||
import { isDesktopTrustedServer } from '@/lib/desktop/trusted-servers';
|
||||
|
||||
|
||||
export interface JmapServerOAuthConfig {
|
||||
clientId?: string;
|
||||
@@ -223,6 +225,10 @@ 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. Spelled out, not IS_DESKTOP_BUILD:
|
||||
// only this form lets the bundler drop the branch and the module behind it, so
|
||||
// that no other build holds this path at all (scripts/standalone-guard.mjs checks).
|
||||
if (process.env.NEXT_PUBLIC_BULWARK_DESKTOP === '1' && isDesktopTrustedServer(requestedUrl)) return trimUrl(requestedUrl);
|
||||
// No match - caller decides whether to honor the request anyway (e.g. when
|
||||
// allowCustomJmapEndpoint is enabled).
|
||||
return null;
|
||||
|
||||
@@ -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<str
|
||||
return { 'X-Forwarded-For': clientIp };
|
||||
}
|
||||
|
||||
/** The client address of `request` as the sign-in routes forward and budget it. */
|
||||
export function loginClientAddress(request: Request): string {
|
||||
/**
|
||||
* The client address of `request` as the sign-in routes forward and budget it.
|
||||
* The desktop build has one client, so there it counts per mail server instead.
|
||||
*/
|
||||
export function loginClientAddress(request: Request, serverUrl?: unknown): string {
|
||||
if (IS_DESKTOP_BUILD) return desktopLoginBudgetKey(serverUrl);
|
||||
return getClientIP(request);
|
||||
}
|
||||
|
||||
+4
-1
@@ -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<PayloadPurpose, (payload: Record<string, un
|
||||
'session-age': () => false,
|
||||
'pairing-store': () => false,
|
||||
'handoff-store': () => false,
|
||||
'custom-upstream': () => false,
|
||||
};
|
||||
|
||||
export function encryptPayload(payload: Record<string, unknown>, purpose: PayloadPurpose): string {
|
||||
|
||||
@@ -10,19 +10,43 @@
|
||||
* waits at most `timeoutMs` for all of them together.
|
||||
*/
|
||||
|
||||
import { getDesktopBridge } from '@/lib/desktop/bridge';
|
||||
|
||||
export type ComposeFlush = () => Promise<void>;
|
||||
|
||||
/** Less than the desktop shell waits for a closing window. */
|
||||
const DESKTOP_FLUSH_MS = 3000;
|
||||
|
||||
let nextId = 1;
|
||||
const flushes = new Map<number, ComposeFlush>();
|
||||
const waiters = new Set<(flush: ComposeFlush) => void>();
|
||||
|
||||
let stopDesktopFlush: (() => void) | null = null;
|
||||
|
||||
/**
|
||||
* In the desktop app, closing a window or quitting saves the open drafts first. The
|
||||
* shell only waits while a composer is here, so a window without one closes at once.
|
||||
*/
|
||||
function syncDesktopFlush(): void {
|
||||
if (flushes.size === 0) {
|
||||
stopDesktopFlush?.();
|
||||
stopDesktopFlush = null;
|
||||
return;
|
||||
}
|
||||
if (stopDesktopFlush) return;
|
||||
const lifecycle = getDesktopBridge()?.lifecycle;
|
||||
if (lifecycle) stopDesktopFlush = lifecycle.onFlushRequest(() => flushComposers(DESKTOP_FLUSH_MS));
|
||||
}
|
||||
|
||||
/** Register a composer's flush; returns the unregister function. */
|
||||
export function registerComposeFlush(flush: ComposeFlush): () => void {
|
||||
const id = nextId++;
|
||||
flushes.set(id, flush);
|
||||
for (const waiter of [...waiters]) waiter(flush);
|
||||
syncDesktopFlush();
|
||||
return () => {
|
||||
flushes.delete(id);
|
||||
syncDesktopFlush();
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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<BulwarkDesktopBridge> | 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> = {};
|
||||
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<string, unknown>) => 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<object>()),
|
||||
isPublicHttpUrl,
|
||||
}));
|
||||
|
||||
vi.mock('@/lib/stalwart/auth-context', () => ({
|
||||
clearStalwartAuthContextInStore: vi.fn(),
|
||||
readStalwartAuthContextFromStore: () => null,
|
||||
}));
|
||||
|
||||
vi.setConfig({ testTimeout: 60_000 });
|
||||
|
||||
class FakeCookies {
|
||||
store = new Map<string, string>();
|
||||
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<string, string> = {}, 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<typeof PUT>[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<typeof PUT>[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<typeof PUT>[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<typeof PUT>[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<typeof POST>[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<typeof POST>[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<typeof DELETE>[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<typeof PUT>[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<typeof PUT>[0])) as unknown as { status: number };
|
||||
expect(res.status).toBe(500);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,147 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
/**
|
||||
* What the login routes do with a private server, with and without the desktop
|
||||
* build, and whose budget a wrong password spends.
|
||||
*/
|
||||
|
||||
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('next/headers', () => ({
|
||||
cookies: async () => ({ get: () => undefined, set: vi.fn(), delete: vi.fn(), getAll: () => [] }),
|
||||
}));
|
||||
|
||||
const config: Record<string, unknown> = { 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<object>()),
|
||||
resolveJmapIdentity,
|
||||
}));
|
||||
|
||||
const reserveLoginAttempt = vi.hoisted(() => vi.fn());
|
||||
vi.mock('@/lib/auth/verify-budget', async (original) => ({
|
||||
...(await original<object>()),
|
||||
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<typeof POST>[0])) as unknown as {
|
||||
status: number;
|
||||
json: () => Promise<Record<string, unknown>>;
|
||||
};
|
||||
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<symbol, ReadonlySet<string>>)[TRUST_KEY] = new Set([LAN_SERVER]);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
delete (globalThis as unknown as Record<symbol, unknown>)[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<symbol, ReadonlySet<string>>)[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({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,163 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { BulwarkDesktopBridge } from '../types';
|
||||
|
||||
/** What the webmail owes the shell: saving on close, leaving nothing behind on sign-out. */
|
||||
|
||||
function setBridge(bridge: Partial<BulwarkDesktopBridge> | undefined) {
|
||||
window.bulwarkDesktop = bridge as BulwarkDesktopBridge | undefined;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
setBridge(undefined);
|
||||
vi.unstubAllEnvs();
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe('saving drafts before a window closes', () => {
|
||||
function lifecycleBridge() {
|
||||
const handlers = new Set<() => Promise<unknown>>();
|
||||
setBridge({
|
||||
version: 1,
|
||||
platform: 'win32',
|
||||
lifecycle: {
|
||||
onFlushRequest: (handler) => {
|
||||
handlers.add(handler);
|
||||
return () => handlers.delete(handler);
|
||||
},
|
||||
},
|
||||
});
|
||||
return handlers;
|
||||
}
|
||||
|
||||
it('asks to be told only while a composer is open', async () => {
|
||||
const handlers = lifecycleBridge();
|
||||
const { registerComposeFlush } = await import('@/lib/compose-flush-registry');
|
||||
expect(handlers.size).toBe(0);
|
||||
const stop = registerComposeFlush(async () => {});
|
||||
expect(handlers.size).toBe(1);
|
||||
const stopSecond = registerComposeFlush(async () => {});
|
||||
expect(handlers.size).toBe(1);
|
||||
stop();
|
||||
expect(handlers.size).toBe(1);
|
||||
stopSecond();
|
||||
expect(handlers.size).toBe(0);
|
||||
});
|
||||
|
||||
it('a request runs every composer\'s flush and answers when they are done', async () => {
|
||||
const handlers = lifecycleBridge();
|
||||
const { registerComposeFlush } = await import('@/lib/compose-flush-registry');
|
||||
const saved: string[] = [];
|
||||
let release = () => {};
|
||||
const stopA = registerComposeFlush(async () => {
|
||||
await new Promise<void>((resolve) => (release = resolve));
|
||||
saved.push('a');
|
||||
});
|
||||
const stopB = registerComposeFlush(async () => {
|
||||
saved.push('b');
|
||||
});
|
||||
let answered = false;
|
||||
const answer = [...handlers][0]().then(() => (answered = true));
|
||||
await Promise.resolve();
|
||||
expect(answered).toBe(false);
|
||||
release();
|
||||
await answer;
|
||||
expect(saved.sort()).toEqual(['a', 'b']);
|
||||
stopA();
|
||||
stopB();
|
||||
});
|
||||
|
||||
it('a composer that hangs does not hold the answer past its limit', async () => {
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const handlers = lifecycleBridge();
|
||||
const { registerComposeFlush } = await import('@/lib/compose-flush-registry');
|
||||
const stop = registerComposeFlush(() => new Promise(() => {}));
|
||||
let answered = false;
|
||||
void [...handlers][0]().then(() => (answered = true));
|
||||
await vi.advanceTimersByTimeAsync(3001);
|
||||
expect(answered).toBe(true);
|
||||
stop();
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
|
||||
it('in a browser nothing is asked of a bridge that is not there', async () => {
|
||||
const { registerComposeFlush } = await import('@/lib/compose-flush-registry');
|
||||
expect(() => registerComposeFlush(async () => {})()).not.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the last account on a server', () => {
|
||||
async function accountsOn(...servers: string[]) {
|
||||
const { useAccountStore } = await import('@/stores/account-store');
|
||||
useAccountStore.setState({ accounts: [], activeAccountId: null, defaultAccountId: null });
|
||||
const base = { rememberMe: true, lastLoginAt: 0, isConnected: true, hasError: false, isDefault: false, authMode: 'basic' as const };
|
||||
const ids = servers.map((serverUrl, index) =>
|
||||
useAccountStore.getState().addAccount({ ...base, label: `u${index}`, serverUrl, username: `u${index}`, displayName: `u${index}`, email: `u${index}@example.org` }),
|
||||
);
|
||||
return { ids, remove: (id: string) => useAccountStore.getState().removeAccount(id) };
|
||||
}
|
||||
|
||||
function forgetBridge() {
|
||||
const forget = vi.fn();
|
||||
setBridge({ version: 1, platform: 'win32', servers: { prepare: vi.fn(), noteSession: vi.fn(), forget } });
|
||||
return forget;
|
||||
}
|
||||
|
||||
it('leaving takes the server off the list', async () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
|
||||
const forget = forgetBridge();
|
||||
const { ids, remove } = await accountsOn('https://mail.example.org');
|
||||
remove(ids[0]);
|
||||
expect(forget).toHaveBeenCalledWith('https://mail.example.org', 'signed-out');
|
||||
});
|
||||
|
||||
it('with another account still there, it stays', async () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
|
||||
const forget = forgetBridge();
|
||||
const { ids, remove } = await accountsOn('https://mail.example.org', 'https://mail.example.org/');
|
||||
remove(ids[0]);
|
||||
expect(forget).not.toHaveBeenCalled();
|
||||
remove(ids[1]);
|
||||
expect(forget).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('outside the desktop build nothing is called', async () => {
|
||||
const forget = forgetBridge();
|
||||
const { ids, remove } = await accountsOn('https://mail.example.org');
|
||||
remove(ids[0]);
|
||||
expect(forget).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('a plain http account in the desktop build', () => {
|
||||
async function client(serverUrl: string) {
|
||||
const { JMAPClient } = await import('@/lib/jmap/client');
|
||||
const instance = new JMAPClient(serverUrl, 'u', 'p');
|
||||
(instance as unknown as { session: unknown }).session = { eventSourceUrl: `${serverUrl}/jmap/eventsource/`, capabilities: {} };
|
||||
return instance;
|
||||
}
|
||||
|
||||
it('polls: no push stream to pin the app origin\'s sockets', async () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
|
||||
expect((await client('http://192.0.2.5:8080')).getEventSourceUrl()).toBeNull();
|
||||
}, 60_000);
|
||||
|
||||
it('an https account keeps its stream', async () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
|
||||
expect((await client('https://mail.example.org')).getEventSourceUrl()).toBe('https://mail.example.org/jmap/eventsource/');
|
||||
}, 60_000);
|
||||
|
||||
it('a server build keeps it for any server', async () => {
|
||||
expect((await client('http://192.0.2.5:8080')).getEventSourceUrl()).toBe('http://192.0.2.5:8080/jmap/eventsource/');
|
||||
}, 60_000);
|
||||
|
||||
it('a blob gets back the type the route took off', async () => {
|
||||
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
|
||||
const { withDesktopBlobType } = await import('../jmap-route');
|
||||
const blob = new Blob(['x'], { type: 'application/octet-stream' });
|
||||
expect(withDesktopBlobType(blob, 'http://192.0.2.5:8080', 'image/png').type).toBe('image/png');
|
||||
expect(withDesktopBlobType(blob, 'https://mail.example.org', 'image/png')).toBe(blob);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
// @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<symbol, ReadonlySet<string> | 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: Record<string, string>, runtimeEnv: Record<string, string>): Promise<boolean> {
|
||||
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 as unknown as NodeJS.ProcessEnv)) },
|
||||
});
|
||||
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({} as unknown as NodeJS.ProcessEnv)).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('the trust check uses the form the bundler can drop', () => {
|
||||
for (const file of ['lib/admin/jmap-servers.ts', 'app/api/auth/totp-token-exchange/route.ts']) {
|
||||
const source = readFileSync(path.join(ROOT, file), 'utf8');
|
||||
expect(source, file).toMatch(/process\.env\.NEXT_PUBLIC_BULWARK_DESKTOP === '1' && isDesktopTrustedServer/);
|
||||
expect(source, file).not.toMatch(/IS_DESKTOP_BUILD && isDesktopTrustedServer/);
|
||||
}
|
||||
});
|
||||
|
||||
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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/** Nothing added for this server stays: see DesktopServersBridge. */
|
||||
export function forgetDesktopServer(serverUrl: string, reason: 'failed' | 'signed-out'): void {
|
||||
getDesktopBridge()?.servers?.forget?.(serverUrl, reason);
|
||||
}
|
||||
|
||||
/** True in any window of the desktop app, standalone or on an instance. */
|
||||
export function isDesktopApp(): boolean {
|
||||
return getDesktopBridge() !== null;
|
||||
}
|
||||
@@ -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:<port> 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<DesktopBrowserSignInOutcome> {
|
||||
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) };
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* 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 a plain http address as
|
||||
* mixed content, whatever the CSP says. The embedded server forwards
|
||||
* `/__jmap/<id>/...` 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;
|
||||
}
|
||||
}
|
||||
|
||||
/** True for a server the page reaches through the embedded server. */
|
||||
export function isDesktopProxiedServer(serverUrl: string): boolean {
|
||||
return desktopJmapBase(serverUrl) !== serverUrl;
|
||||
}
|
||||
|
||||
/**
|
||||
* The route answers everything but JSON as octet-stream, so that nothing can render
|
||||
* on the app's origin. A blob for the page gets back the type it asked for.
|
||||
*/
|
||||
export function withDesktopBlobType(blob: Blob, serverUrl: string, type: string | undefined): Blob {
|
||||
return type && isDesktopProxiedServer(serverUrl) ? new Blob([blob], { type }) : blob;
|
||||
}
|
||||
|
||||
/** 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)}`;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
@@ -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<string>;
|
||||
|
||||
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<symbol, TrustedOrigins | undefined>;
|
||||
return holder[Symbol.for('bulwark.desktop.trustedJmapOrigins')]?.has(origin) === true;
|
||||
}
|
||||
+111
-5
@@ -2,21 +2,127 @@
|
||||
* The desktop app's preload bridge as the webmail sees it. Feature checks ask
|
||||
* `window.bulwarkDesktop?.<area>`; in a browser the object isn't there.
|
||||
*
|
||||
* Only what the webmail uses so far. The desktop shell fills it in.
|
||||
* 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. A `name` that is already open focuses that
|
||||
* window and doesn't load `url` again: one window per message. Left out, the
|
||||
* page uses window.open with a features string, which the shell's
|
||||
* window-open handler sees too.
|
||||
* 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<DesktopWindowInfo[]>;
|
||||
/** 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<string, boolean | { enabled?: boolean; checked?: boolean }>) => 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<DesktopServerPrepareResult>;
|
||||
/**
|
||||
* The sign-in worked, so the server stays: what `prepare` added lasts only until
|
||||
* the app quits before this. Other origins a session names get a prompt.
|
||||
*/
|
||||
noteSession: (
|
||||
serverUrl: string,
|
||||
urls: { apiUrl?: string; downloadUrl?: string; uploadUrl?: string; eventSourceUrl?: string },
|
||||
) => void;
|
||||
/** A sign-in that failed, or the last account on this server signing out. */
|
||||
forget?: (serverUrl: string, reason: 'failed' | 'signed-out') => void;
|
||||
}
|
||||
|
||||
export interface DesktopLifecycleBridge {
|
||||
/**
|
||||
* Before a window closes or the app quits, the shell asks the page to save what
|
||||
* it has and waits a few seconds. A window with no handler closes at once.
|
||||
*/
|
||||
onFlushRequest: (handler: () => Promise<unknown>) => () => 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<DesktopBrowserSignInResult>;
|
||||
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, after a native prompt. No version means the latest. The
|
||||
* renderer never holds an admin session. Standalone only.
|
||||
*/
|
||||
install: (slug: string, version?: string) => Promise<DesktopPluginInstallResult>;
|
||||
}
|
||||
|
||||
export interface BulwarkDesktopBridge {
|
||||
version: number;
|
||||
platform: 'win32' | 'darwin' | 'linux';
|
||||
mode?: 'standalone' | 'instance';
|
||||
windows?: DesktopWindowsBridge;
|
||||
theme?: DesktopThemeBridge;
|
||||
menu?: DesktopMenuBridge;
|
||||
shell?: DesktopShellBridge;
|
||||
lifecycle?: DesktopLifecycleBridge;
|
||||
servers?: DesktopServersBridge;
|
||||
auth?: DesktopAuthBridge;
|
||||
plugins?: DesktopPluginsBridge;
|
||||
}
|
||||
|
||||
declare global {
|
||||
|
||||
+24
-5
@@ -1,4 +1,6 @@
|
||||
import { recordJmapCall, reportJmapFailure } from "@/lib/error-reporting/client";
|
||||
import { getDesktopBridge } from '@/lib/desktop/bridge';
|
||||
import { desktopJmapBase, desktopJmapOrigin, isDesktopProxiedServer, withDesktopBlobType } 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";
|
||||
@@ -1910,7 +1912,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
const retryHeaders = { ...init?.headers as Record<string, string>, '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 {
|
||||
@@ -1960,7 +1962,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
* and retry directly against the final URL so we can re-send Authorization.
|
||||
*/
|
||||
private async fetchSessionResponse(): Promise<Response> {
|
||||
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
|
||||
@@ -2003,7 +2005,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
async connect(): Promise<void> {
|
||||
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();
|
||||
@@ -2160,10 +2162,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);
|
||||
}
|
||||
@@ -2196,6 +2203,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,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -6508,7 +6523,7 @@ export class JMAPClient implements IJMAPClient {
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch blob: ${response.status}`);
|
||||
}
|
||||
const blob = await response.blob();
|
||||
const blob = withDesktopBlobType(await response.blob(), this.serverUrl, type);
|
||||
getOfflineHooks()?.recordBlob(this, blobId, accountId || this.accountId, blob);
|
||||
return blob;
|
||||
}
|
||||
@@ -6991,6 +7006,10 @@ export class JMAPClient implements IJMAPClient {
|
||||
|
||||
getEventSourceUrl(): string | null {
|
||||
if (!this.session) return null;
|
||||
// A plain http server in the desktop app shares the app's own origin, on
|
||||
// http/1.1 and six sockets, with every window. Push streams pinning those
|
||||
// would stall the app, so these accounts poll (setupPushNotifications).
|
||||
if (isDesktopProxiedServer(this.serverUrl)) return null;
|
||||
|
||||
// RFC 8620: session root level, with fallback to capabilities for some servers
|
||||
const coreCapability = this.session.capabilities?.["urn:ietf:params:jmap:core"] as { eventSourceUrl?: string } | undefined;
|
||||
|
||||
@@ -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<ReturnType<typeof cookies>>;
|
||||
|
||||
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<CustomUpstream | null> {
|
||||
if (typeof serverUrl !== 'string' || !serverUrl || typeof clientId !== 'string' || !clientId) return null;
|
||||
await configManager.ensureLoaded();
|
||||
const servers = parseJmapServers(configManager.get<unknown>('jmapServers', []));
|
||||
const trusted = resolveTrustedJmapUrl(serverUrl, configManager.get<string>('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<CustomUpstream, 'serverUrl' | 'clientId'>,
|
||||
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<CustomUpstream | null> {
|
||||
const value = cookieStore.get(customUpstreamCookieName(slot))?.value;
|
||||
if (!value) return null;
|
||||
let payload: Record<string, unknown> | 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);
|
||||
}
|
||||
@@ -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<string>('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<ClientConfigOptions> {
|
||||
export async function defaultClientOptions(serverId?: string | null, upstream?: CustomUpstream | null): Promise<ClientConfigOptions> {
|
||||
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<string, string>, 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<TokenResult> {
|
||||
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
|
||||
|
||||
@@ -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';
|
||||
@@ -325,7 +326,7 @@ export function probeServer(serverUrl: string): Promise<boolean> {
|
||||
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 {
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getDesktopBridge } from '@/lib/desktop/bridge';
|
||||
|
||||
/**
|
||||
* Keeps <meta name="theme-color"> 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<HTMLMetaElement>('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 <ThemeColorSync />. */
|
||||
export function enableThemeColorSync(): void {
|
||||
enabled = true;
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -6500,5 +6500,14 @@
|
||||
"message_moved": "تم نقل هذه الرسالة إلى مجلد آخر.",
|
||||
"message_moved_to": "تم نقل هذه الرسالة إلى {folder}.",
|
||||
"window_not_ready": "لم تفتح النافذة الجديدة في الوقت المناسب. تبقى مسودتك هنا."
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "سجّل الدخول باستخدام المتصفح",
|
||||
"browser_waiting": "أكمل تسجيل الدخول في المتصفح",
|
||||
"browser_unavailable": "لا يتيح هذا الخادم تسجيل الدخول عبر المتصفح. استخدم كلمة المرور بدلًا من ذلك.",
|
||||
"browser_failed": "لم ينجح تسجيل الدخول عبر المتصفح. حاول مرة أخرى.",
|
||||
"server_needed": "أدخل عنوان الخادم أولًا.",
|
||||
"server_declined": "لم تسمح بهذا الخادم، لذا لم يُرسل إليه أي شيء.",
|
||||
"server_invalid": "عنوان الخادم هذا غير صالح."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6500,5 +6500,14 @@
|
||||
"message_moved": "Aquest missatge s’ha mogut a una altra carpeta.",
|
||||
"message_moved_to": "Aquest missatge s’ha mogut a {folder}.",
|
||||
"window_not_ready": "La finestra nova no s’ha obert a temps. L’esborrany es queda aquí."
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6500,5 +6500,14 @@
|
||||
"message_moved": "Tato zpráva byla přesunuta do jiné složky.",
|
||||
"message_moved_to": "Tato zpráva byla přesunuta do {folder}.",
|
||||
"window_not_ready": "Nové okno se neotevřelo včas. Koncept zůstává zde."
|
||||
},
|
||||
"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á."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6500,5 +6500,14 @@
|
||||
"message_moved": "Denne besked er flyttet til en anden mappe.",
|
||||
"message_moved_to": "Denne besked er flyttet til {folder}.",
|
||||
"window_not_ready": "Det nye vindue åbnede ikke i tide. Din kladde bliver her."
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6500,5 +6500,14 @@
|
||||
"message_moved": "Diese Nachricht wurde in einen anderen Ordner verschoben.",
|
||||
"message_moved_to": "Diese Nachricht wurde nach {folder} verschoben.",
|
||||
"window_not_ready": "Das neue Fenster wurde nicht rechtzeitig geöffnet. Ihr Entwurf bleibt hier."
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6500,5 +6500,14 @@
|
||||
"message_moved": "This message was moved to another folder.",
|
||||
"message_moved_to": "This message was moved to {folder}.",
|
||||
"window_not_ready": "The new window did not open in time. Your draft stays here."
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6500,5 +6500,14 @@
|
||||
"message_moved": "Este mensaje se movió a otra carpeta.",
|
||||
"message_moved_to": "Este mensaje se movió a {folder}.",
|
||||
"window_not_ready": "La ventana nueva no se abrió a tiempo. Tu borrador se queda aquí."
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6500,5 +6500,14 @@
|
||||
"message_moved": "See kiri teisaldati teise kausta.",
|
||||
"message_moved_to": "See kiri teisaldati kausta {folder}.",
|
||||
"window_not_ready": "Uus aken ei avanenud õigeks ajaks. Mustand jääb siia."
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6500,5 +6500,14 @@
|
||||
"message_moved": "این پیام به پوشهٔ دیگری منتقل شد.",
|
||||
"message_moved_to": "این پیام به {folder} منتقل شد.",
|
||||
"window_not_ready": "پنجرهٔ جدید بهموقع باز نشد. پیشنویس شما همینجا میماند."
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "ورود با مرورگر",
|
||||
"browser_waiting": "ورود را در مرورگر کامل کنید",
|
||||
"browser_unavailable": "این سرور ورود از طریق مرورگر را ارائه نمیدهد. از گذرواژه خود استفاده کنید.",
|
||||
"browser_failed": "ورود از طریق مرورگر انجام نشد. دوباره تلاش کنید.",
|
||||
"server_needed": "ابتدا نشانی سرور را وارد کنید.",
|
||||
"server_declined": "شما این سرور را مجاز نکردید، پس چیزی به آن فرستاده نشد.",
|
||||
"server_invalid": "این نشانی سرور معتبر نیست."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6500,5 +6500,14 @@
|
||||
"message_moved": "Ce message a été déplacé vers un autre dossier.",
|
||||
"message_moved_to": "Ce message a été déplacé vers {folder}.",
|
||||
"window_not_ready": "La nouvelle fenêtre ne s’est pas ouverte à temps. Votre brouillon reste ici."
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6501,5 +6501,14 @@
|
||||
"message_moved": "ההודעה הזו הועברה לתיקייה אחרת.",
|
||||
"message_moved_to": "ההודעה הזו הועברה אל {folder}.",
|
||||
"window_not_ready": "החלון החדש לא נפתח בזמן. הטיוטה נשארת כאן."
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "כניסה באמצעות הדפדפן",
|
||||
"browser_waiting": "יש להשלים את הכניסה בדפדפן",
|
||||
"browser_unavailable": "השרת הזה לא מאפשר כניסה דרך דפדפן. יש להשתמש בסיסמה.",
|
||||
"browser_failed": "הכניסה דרך הדפדפן לא הצליחה. כדאי לנסות שוב.",
|
||||
"server_needed": "קודם צריך להזין את כתובת השרת.",
|
||||
"server_declined": "לא אישרת את השרת הזה, לכן לא נשלח אליו דבר.",
|
||||
"server_invalid": "כתובת השרת הזו לא תקינה."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6500,5 +6500,14 @@
|
||||
"message_moved": "Ezt az üzenetet másik mappába helyezték át.",
|
||||
"message_moved_to": "Ezt az üzenetet ide helyezték át: {folder}.",
|
||||
"window_not_ready": "Az új ablak nem nyílt meg időben. A piszkozat itt marad."
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6500,5 +6500,14 @@
|
||||
"message_moved": "Questo messaggio è stato spostato in un’altra cartella.",
|
||||
"message_moved_to": "Questo messaggio è stato spostato in {folder}.",
|
||||
"window_not_ready": "La nuova finestra non si è aperta in tempo. La bozza rimane qui."
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6500,5 +6500,14 @@
|
||||
"message_moved": "このメッセージは別のフォルダーに移動されました。",
|
||||
"message_moved_to": "このメッセージは {folder} に移動されました。",
|
||||
"window_not_ready": "新しいウィンドウが時間内に開きませんでした。下書きはここに残ります。"
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "ブラウザでサインイン",
|
||||
"browser_waiting": "ブラウザでサインインを完了してください",
|
||||
"browser_unavailable": "このサーバーはブラウザでのサインインに対応していません。パスワードを使用してください。",
|
||||
"browser_failed": "ブラウザでのサインインに失敗しました。もう一度お試しください。",
|
||||
"server_needed": "先にサーバーのアドレスを入力してください。",
|
||||
"server_declined": "このサーバーを許可しなかったため、何も送信していません。",
|
||||
"server_invalid": "このサーバーアドレスは正しくありません。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6500,5 +6500,14 @@
|
||||
"message_moved": "이 메시지는 다른 폴더로 이동되었습니다.",
|
||||
"message_moved_to": "이 메시지는 {folder}(으)로 이동되었습니다.",
|
||||
"window_not_ready": "새 창이 제때 열리지 않았습니다. 임시 보관 메일은 여기에 남아 있습니다."
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "브라우저로 로그인",
|
||||
"browser_waiting": "브라우저에서 로그인을 마치세요",
|
||||
"browser_unavailable": "이 서버는 브라우저 로그인을 지원하지 않습니다. 비밀번호를 사용하세요.",
|
||||
"browser_failed": "브라우저 로그인에 실패했습니다. 다시 시도하세요.",
|
||||
"server_needed": "먼저 서버 주소를 입력하세요.",
|
||||
"server_declined": "이 서버를 허용하지 않아 아무것도 보내지 않았습니다.",
|
||||
"server_invalid": "이 서버 주소는 올바르지 않습니다."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6500,5 +6500,14 @@
|
||||
"message_moved": "Šis ziņojums tika pārvietots uz citu mapi.",
|
||||
"message_moved_to": "Šis ziņojums tika pārvietots uz {folder}.",
|
||||
"window_not_ready": "Jaunais logs neatvērās laikus. Melnraksts paliek šeit."
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6500,5 +6500,14 @@
|
||||
"message_moved": "Энэ мессежийг өөр хавтас руу зөөсөн.",
|
||||
"message_moved_to": "Энэ мессежийг {folder} руу зөөсөн.",
|
||||
"window_not_ready": "Шинэ цонх цагтаа нээгдсэнгүй. Таны ноорог энд үлдэнэ."
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "Хөтчөөр нэвтрэх",
|
||||
"browser_waiting": "Нэвтрэлтээ хөтөч дээр дуусгана уу",
|
||||
"browser_unavailable": "Энэ сервер хөтчөөр нэвтрэх боломжгүй. Нууц үгээ ашиглана уу.",
|
||||
"browser_failed": "Хөтчөөр нэвтэрч чадсангүй. Дахин оролдоно уу.",
|
||||
"server_needed": "Эхлээд серверийн хаягаа оруулна уу.",
|
||||
"server_declined": "Та энэ серверийг зөвшөөрөөгүй тул юу ч илгээгээгүй.",
|
||||
"server_invalid": "Энэ серверийн хаяг буруу байна."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6500,5 +6500,14 @@
|
||||
"message_moved": "Denne meldingen ble flyttet til en annen mappe.",
|
||||
"message_moved_to": "Denne meldingen ble flyttet til {folder}.",
|
||||
"window_not_ready": "Det nye vinduet åpnet seg ikke i tide. Utkastet blir her."
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6500,5 +6500,14 @@
|
||||
"message_moved": "Dit bericht is naar een andere map verplaatst.",
|
||||
"message_moved_to": "Dit bericht is verplaatst naar {folder}.",
|
||||
"window_not_ready": "Het nieuwe venster is niet op tijd geopend. Je concept blijft hier."
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6500,5 +6500,14 @@
|
||||
"message_moved": "Ta wiadomość została przeniesiona do innego folderu.",
|
||||
"message_moved_to": "Ta wiadomość została przeniesiona do {folder}.",
|
||||
"window_not_ready": "Nowe okno nie otworzyło się na czas. Twój szkic zostaje tutaj."
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6500,5 +6500,14 @@
|
||||
"message_moved": "Esta mensagem foi movida para outra pasta.",
|
||||
"message_moved_to": "Esta mensagem foi movida para {folder}.",
|
||||
"window_not_ready": "A nova janela não abriu a tempo. O seu rascunho fica aqui."
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6500,5 +6500,14 @@
|
||||
"message_moved": "Acest mesaj a fost mutat în alt dosar.",
|
||||
"message_moved_to": "Acest mesaj a fost mutat în {folder}.",
|
||||
"window_not_ready": "Fereastra nouă nu s-a deschis la timp. Ciorna rămâne aici."
|
||||
},
|
||||
"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ă."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6500,5 +6500,14 @@
|
||||
"message_moved": "Это письмо перемещено в другую папку.",
|
||||
"message_moved_to": "Это письмо перемещено в {folder}.",
|
||||
"window_not_ready": "Новое окно не открылось вовремя. Черновик остаётся здесь."
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "Войти через браузер",
|
||||
"browser_waiting": "Завершите вход в браузере",
|
||||
"browser_unavailable": "Этот сервер не поддерживает вход через браузер. Используйте пароль.",
|
||||
"browser_failed": "Не удалось войти через браузер. Попробуйте ещё раз.",
|
||||
"server_needed": "Сначала введите адрес сервера.",
|
||||
"server_declined": "Вы не разрешили этот сервер, поэтому ему ничего не отправлено.",
|
||||
"server_invalid": "Этот адрес сервера недействителен."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6500,5 +6500,14 @@
|
||||
"message_moved": "Táto správa bola presunutá do iného priečinka.",
|
||||
"message_moved_to": "Táto správa bola presunutá do {folder}.",
|
||||
"window_not_ready": "Nové okno sa neotvorilo včas. Koncept zostáva tu."
|
||||
},
|
||||
"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á."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6508,5 +6508,14 @@
|
||||
"message_moved": "ข้อความนี้ถูกย้ายไปยังโฟลเดอร์อื่น",
|
||||
"message_moved_to": "ข้อความนี้ถูกย้ายไปยัง {folder}",
|
||||
"window_not_ready": "หน้าต่างใหม่เปิดไม่ทัน แบบร่างของคุณยังอยู่ที่นี่"
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "ลงชื่อเข้าใช้ด้วยเบราว์เซอร์",
|
||||
"browser_waiting": "ลงชื่อเข้าใช้ให้เสร็จในเบราว์เซอร์",
|
||||
"browser_unavailable": "เซิร์ฟเวอร์นี้ไม่รองรับการลงชื่อเข้าใช้ผ่านเบราว์เซอร์ โปรดใช้รหัสผ่าน",
|
||||
"browser_failed": "ลงชื่อเข้าใช้ผ่านเบราว์เซอร์ไม่สำเร็จ โปรดลองอีกครั้ง",
|
||||
"server_needed": "ป้อนที่อยู่เซิร์ฟเวอร์ก่อน",
|
||||
"server_declined": "คุณไม่ได้อนุญาตเซิร์ฟเวอร์นี้ จึงไม่มีการส่งข้อมูลใดไป",
|
||||
"server_invalid": "ที่อยู่เซิร์ฟเวอร์นี้ไม่ถูกต้อง"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6500,5 +6500,14 @@
|
||||
"message_moved": "Bu ileti başka bir klasöre taşındı.",
|
||||
"message_moved_to": "Bu ileti {folder} klasörüne taşındı.",
|
||||
"window_not_ready": "Yeni pencere zamanında açılmadı. Taslağınız burada kalıyor."
|
||||
},
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6500,5 +6500,14 @@
|
||||
"message_moved": "Цього листа переміщено в іншу папку.",
|
||||
"message_moved_to": "Цього листа переміщено в {folder}.",
|
||||
"window_not_ready": "Нове вікно не відкрилося вчасно. Чернетка залишається тут."
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "Увійти через браузер",
|
||||
"browser_waiting": "Завершіть вхід у браузері",
|
||||
"browser_unavailable": "Цей сервер не підтримує вхід через браузер. Використайте пароль.",
|
||||
"browser_failed": "Не вдалося увійти через браузер. Спробуйте ще раз.",
|
||||
"server_needed": "Спочатку введіть адресу сервера.",
|
||||
"server_declined": "Ви не дозволили цей сервер, тому йому нічого не надіслано.",
|
||||
"server_invalid": "Ця адреса сервера недійсна."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6512,5 +6512,14 @@
|
||||
"message_moved": "Thư này đã được chuyển sang thư mục khác.",
|
||||
"message_moved_to": "Thư này đã được chuyển đến {folder}.",
|
||||
"window_not_ready": "Cửa sổ mới không mở kịp. Bản nháp của bạn vẫn ở đây."
|
||||
},
|
||||
"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ệ."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6500,5 +6500,14 @@
|
||||
"message_moved": "這封郵件已移到其他資料夾。",
|
||||
"message_moved_to": "這封郵件已移到 {folder}。",
|
||||
"window_not_ready": "新視窗未能及時開啟。你的草稿仍保留在這裡。"
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "使用瀏覽器登入",
|
||||
"browser_waiting": "請在瀏覽器中完成登入",
|
||||
"browser_unavailable": "此伺服器不支援透過瀏覽器登入。請使用密碼。",
|
||||
"browser_failed": "無法透過瀏覽器登入。請再試一次。",
|
||||
"server_needed": "請先輸入伺服器位址。",
|
||||
"server_declined": "你沒有允許此伺服器,因此沒有傳送任何內容給它。",
|
||||
"server_invalid": "此伺服器位址無效。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6500,5 +6500,14 @@
|
||||
"message_moved": "这封邮件已移动到其他文件夹。",
|
||||
"message_moved_to": "这封邮件已移动到 {folder}。",
|
||||
"window_not_ready": "新窗口未能及时打开。你的草稿仍保留在这里。"
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "使用浏览器登录",
|
||||
"browser_waiting": "请在浏览器中完成登录",
|
||||
"browser_unavailable": "此服务器不支持通过浏览器登录。请使用密码。",
|
||||
"browser_failed": "无法通过浏览器登录。请重试。",
|
||||
"server_needed": "请先输入服务器地址。",
|
||||
"server_declined": "你没有允许此服务器,因此没有向它发送任何内容。",
|
||||
"server_invalid": "此服务器地址无效。"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
// 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';
|
||||
|
||||
/**
|
||||
* A read of the flag that survived compiling: then setting it at runtime would
|
||||
* work. Only the read: Next also writes the bare name into server.js with the
|
||||
* config's `env` block, as a JSON key with its compiled value.
|
||||
*/
|
||||
export const DESKTOP_FLAG_READ = /process\.env(\.NEXT_PUBLIC_BULWARK_DESKTOP\b|\[\s*["'`]NEXT_PUBLIC_BULWARK_DESKTOP["'`]\s*\])/;
|
||||
|
||||
export function desktopBuildFlag(env = process.env) {
|
||||
return env.NEXT_PUBLIC_BULWARK_DESKTOP === '1' ? '1' : '';
|
||||
}
|
||||
@@ -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_FLAG_READ, 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,35 @@ 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')) {
|
||||
const text = readFileSync(full, 'utf8');
|
||||
if (text.includes(DESKTOP_TRUST_MARKER) || DESKTOP_FLAG_READ.test(text)) {
|
||||
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) {
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
import { create } from 'zustand';
|
||||
import { createJSONStorage, persist } from 'zustand/middleware';
|
||||
import { mainWindowStorage } from '@/lib/popout/window-role';
|
||||
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
|
||||
import { forgetDesktopServer } from '@/lib/desktop/bridge';
|
||||
import { generateAccountId, generateAvatarColor, getMaxAccounts } from '@/lib/account-utils';
|
||||
import { authHooks } from '@/lib/plugin-hooks';
|
||||
|
||||
function sameServerOrigin(a: string, b: string): boolean {
|
||||
try {
|
||||
return new URL(a).origin === new URL(b).origin;
|
||||
} catch {
|
||||
return a === b;
|
||||
}
|
||||
}
|
||||
|
||||
export interface AccountEntry {
|
||||
/** Unique key: `${username}@${serverHostname}` */
|
||||
id: string;
|
||||
@@ -150,6 +160,7 @@ export const useAccountStore = create<AccountState>()(
|
||||
|
||||
removeAccount: (accountId) => {
|
||||
if (!get().accounts.some((a) => a.id === accountId)) return;
|
||||
const leaving = get().accounts.find((a) => a.id === accountId);
|
||||
set((s) => {
|
||||
const remaining = s.accounts.filter((a) => a.id !== accountId);
|
||||
const wasDefault = s.defaultAccountId === accountId;
|
||||
@@ -174,6 +185,10 @@ export const useAccountStore = create<AccountState>()(
|
||||
};
|
||||
});
|
||||
void authHooks.onAccountRemove.emit({ accountId });
|
||||
// The desktop app stops trusting a server its last account left.
|
||||
if (IS_DESKTOP_BUILD && leaving && !get().accounts.some((a) => sameServerOrigin(a.serverUrl, leaving.serverUrl))) {
|
||||
forgetDesktopServer(leaving.serverUrl, 'signed-out');
|
||||
}
|
||||
},
|
||||
|
||||
setActiveAccount: (accountId) => {
|
||||
|
||||
@@ -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';
|
||||
@@ -479,6 +482,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));
|
||||
}
|
||||
@@ -1049,6 +1054,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;
|
||||
|
||||
Reference in New Issue
Block a user