feat: sign in through the system browser in the desktop app, and refresh at the account's own server

This commit is contained in:
Linus Rath
2026-11-03 06:32:43 +01:00
parent e65f7fe31f
commit aa511e5d38
42 changed files with 1153 additions and 20 deletions
+11 -2
View File
@@ -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');
+62
View File
@@ -1,6 +1,9 @@
"use client";
import { Suspense, useState, useEffect, useRef, useCallback, useId } from "react";
import { IS_DESKTOP_BUILD } from "@/lib/desktop/flag";
import { getDesktopBridge } from "@/lib/desktop/bridge";
import { browserSignInAvailable, runDesktopBrowserSignIn } from "@/lib/desktop/browser-sign-in";
import { useRouter } from "@/i18n/navigation";
import { useParams, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
@@ -185,6 +188,10 @@ function VersionBadge() {
function LoginPageContent() {
const router = useRouter();
const t = useTranslations("login");
const tDesktop = useTranslations("desktop_sign_in");
// Only the desktop build sets these; a browser never shows them.
const [desktopNotice, setDesktopNotice] = useState<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,33 @@ function LoginPageContent() {
window.location.href = authUrl.toString();
};
/** The desktop build: the server's own sign-in page in the system browser. */
const handleDesktopBrowserSignIn = async () => {
const target = jmapEndpoint.trim();
if (!target) {
setDesktopNotice(tDesktop("server_needed"));
return;
}
setDesktopNotice(null);
setDesktopWaiting(true);
const prefix = getPathPrefix(params.locale as string);
const outcome = await runDesktopBrowserSignIn({
serverUrl: target,
callbackPath: `${prefix}/${params.locale}/auth/callback`,
slot: useAccountStore.getState().getNextCookieSlot(),
remember: persistProviderSession,
addAccount: isAddAccountMode,
});
if (outcome.kind === "callback") {
window.location.href = outcome.callbackUrl;
return;
}
setDesktopWaiting(false);
if (outcome.kind === "unavailable") setDesktopNotice(tDesktop("browser_unavailable"));
else if (outcome.kind === "declined") setDesktopNotice(tDesktop("server_declined"));
else if (outcome.kind === "failed") setDesktopNotice(tDesktop("browser_failed"));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -992,6 +1026,16 @@ function LoginPageContent() {
// when the admin hasn't configured a server list.
const effectiveServerUrl = selectedServer?.url
|| (allowCustomJmapEndpoint ? jmapEndpoint : serverUrl);
// A LAN or plain http server gets a native prompt first, and the app can
// reach it from then on. Without a yes, nothing is sent there.
if (IS_DESKTOP_BUILD) {
setDesktopNotice(null);
const prepared = await getDesktopBridge()?.servers?.prepare(effectiveServerUrl);
if (prepared && !prepared.ok) {
setDesktopNotice(prepared.reason === "invalid" ? tDesktop("server_invalid") : tDesktop("server_declined"));
return;
}
}
if (signInWithToken) {
if (await loginWithToken(effectiveServerUrl, accessToken, persistSession)) {
router.push('/');
@@ -1814,6 +1858,24 @@ function LoginPageContent() {
</>
)}
{IS_DESKTOP_BUILD && !hasServerList && browserSignInAvailable() && (
<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">
+86
View File
@@ -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 });
}
}
+35 -11
View File
@@ -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;
}
+15 -2
View File
@@ -16,6 +16,10 @@ import { loginClientAddress } from '@/lib/auth/client-address';
import { LOGIN_BUDGET_RETRY_AFTER_SECONDS, reserveLoginAttempt } from '@/lib/auth/verify-budget';
import { isStoreUnavailable, storeUnavailableResponse } from '@/lib/auth/store-unavailable';
import { writeSignInStamp } from '@/lib/auth/session-age';
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
import { isDesktopTrustedServer } from '@/lib/desktop/trusted-servers';
import { stalwartDefaultClientId } from '@/lib/auth/stalwart-password-login';
import { writeCustomUpstream } from '@/lib/oauth/custom-upstream';
/**
* Exchange a password + (optional) TOTP code for OAuth tokens.
@@ -113,7 +117,12 @@ async function attemptLogin(
logger.info('TOTP login succeeded');
void recordLogin(username, base);
return await storeAndRespond(result.tokens, slot, serverId, rememberMe);
// The desktop app's servers are in no configured list, so the refresh has to
// be told where this token belongs and which client it was issued to.
const upstream = IS_DESKTOP_BUILD && !serverId
? { serverUrl: base, clientId: await stalwartDefaultClientId(base, clientId, upstreamTrusted) }
: null;
return await storeAndRespond(result.tokens, slot, serverId, rememberMe, upstream);
}
function trimUrl(url: string): string {
@@ -164,6 +173,8 @@ export async function POST(request: NextRequest) {
resolvedServerId = matchedEntry.id;
} else if (configuredServerUrl && !customRequested) {
upstreamUrl = configuredServerUrl;
} else if (IS_DESKTOP_BUILD && isDesktopTrustedServer(serverUrl)) {
upstreamUrl = serverUrl;
} else if (allowCustomEndpoint) {
if (!(await isPublicHttpUrl(serverUrl))) {
logger.warn('TOTP login: rejected non-public server URL');
@@ -193,7 +204,7 @@ export async function POST(request: NextRequest) {
slot,
resolvedServerId,
upstreamTrusted,
loginClientAddress(request),
loginClientAddress(request, upstreamUrl),
// Clients from before the flag remembered every sign-in.
rememberMe !== false,
);
@@ -210,6 +221,7 @@ async function storeAndRespond(
slot: number,
serverId: string | null,
rememberMe: boolean,
upstream: { serverUrl: string; clientId: string } | null,
): Promise<NextResponse> {
const cookieStore = await cookies();
// A sign-in: the maximum session length counts from here (lib/auth/session-age.ts).
@@ -227,6 +239,7 @@ async function storeAndRespond(
} else {
cookieStore.delete(serverCookieName);
}
if (upstream && tokens.refresh_token) writeCustomUpstream(cookieStore, slot, upstream, rememberMe);
return NextResponse.json({
access_token: tokens.access_token,
+4 -1
View File
@@ -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 {
+233
View File
@@ -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);
});
});
+104
View File
@@ -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) };
}
}
+72
View File
@@ -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);
}
+17 -4
View File
@@ -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
+9
View File
@@ -6451,5 +6451,14 @@
"next_issue": "الخطأ الإملائي أو النحوي التالي",
"previous_issue": "الخطأ الإملائي أو النحوي السابق"
}
},
"desktop_sign_in": {
"browser_button": "سجّل الدخول باستخدام المتصفح",
"browser_waiting": "أكمل تسجيل الدخول في المتصفح",
"browser_unavailable": "لا يتيح هذا الخادم تسجيل الدخول عبر المتصفح. استخدم كلمة المرور بدلًا من ذلك.",
"browser_failed": "لم ينجح تسجيل الدخول عبر المتصفح. حاول مرة أخرى.",
"server_needed": "أدخل عنوان الخادم أولًا.",
"server_declined": "لم تسمح بهذا الخادم، لذا لم يُرسل إليه أي شيء.",
"server_invalid": "عنوان الخادم هذا غير صالح."
}
}
+9
View File
@@ -6451,5 +6451,14 @@
"next_issue": "Error d'ortografia o de gramàtica següent",
"previous_issue": "Error d'ortografia o de gramàtica anterior"
}
},
"desktop_sign_in": {
"browser_button": "Inicia la sessió amb el navegador",
"browser_waiting": "Acaba d'iniciar la sessió al navegador",
"browser_unavailable": "Aquest servidor no permet iniciar la sessió amb el navegador. Fes servir la contrasenya.",
"browser_failed": "No s'ha pogut iniciar la sessió amb el navegador. Torna-ho a provar.",
"server_needed": "Primer escriu l'adreça del servidor.",
"server_declined": "No has permès aquest servidor, així que no s'hi ha enviat res.",
"server_invalid": "Aquesta adreça de servidor no és vàlida."
}
}
+9
View File
@@ -6451,5 +6451,14 @@
"next_issue": "Další pravopisná nebo gramatická chyba",
"previous_issue": "Předchozí pravopisná nebo gramatická chyba"
}
},
"desktop_sign_in": {
"browser_button": "Přihlásit se v prohlížeči",
"browser_waiting": "Dokončete přihlášení v prohlížeči",
"browser_unavailable": "Tento server přihlášení přes prohlížeč nenabízí. Použijte heslo.",
"browser_failed": "Přihlášení přes prohlížeč se nezdařilo. Zkuste to znovu.",
"server_needed": "Nejdřív zadejte adresu serveru.",
"server_declined": "Tento server jste nepovolili, proto se na něj nic neodeslalo.",
"server_invalid": "Tato adresa serveru není platná."
}
}
+9
View File
@@ -6451,5 +6451,14 @@
"next_issue": "Næste stave- eller grammatikfejl",
"previous_issue": "Forrige stave- eller grammatikfejl"
}
},
"desktop_sign_in": {
"browser_button": "Log ind med din browser",
"browser_waiting": "Gør login færdigt i din browser",
"browser_unavailable": "Denne server tilbyder ikke login via browser. Brug din adgangskode i stedet.",
"browser_failed": "Login via browseren lykkedes ikke. Prøv igen.",
"server_needed": "Angiv først serverens adresse.",
"server_declined": "Du tillod ikke denne server, så der blev ikke sendt noget til den.",
"server_invalid": "Denne serveradresse er ikke gyldig."
}
}
+9
View File
@@ -6451,5 +6451,14 @@
"next_issue": "Nächster Rechtschreib- oder Grammatikfehler",
"previous_issue": "Vorheriger Rechtschreib- oder Grammatikfehler"
}
},
"desktop_sign_in": {
"browser_button": "Mit dem Browser anmelden",
"browser_waiting": "Schließen Sie die Anmeldung im Browser ab",
"browser_unavailable": "Dieser Server bietet keine Anmeldung über den Browser an. Verwenden Sie stattdessen Ihr Passwort.",
"browser_failed": "Die Anmeldung über den Browser hat nicht geklappt. Versuchen Sie es noch einmal.",
"server_needed": "Geben Sie zuerst die Adresse Ihres Servers ein.",
"server_declined": "Sie haben diesen Server nicht zugelassen, daher wurde nichts an ihn gesendet.",
"server_invalid": "Diese Serveradresse ist ungültig."
}
}
+9
View File
@@ -6451,5 +6451,14 @@
"next_issue": "Next spelling or grammar issue",
"previous_issue": "Previous spelling or grammar issue"
}
},
"desktop_sign_in": {
"browser_button": "Sign in with your browser",
"browser_waiting": "Finish signing in in your browser",
"browser_unavailable": "This server doesn't offer sign-in through a browser. Use your password instead.",
"browser_failed": "Signing in through your browser didn't work. Try again.",
"server_needed": "Enter your server's address first.",
"server_declined": "You didn't allow this server, so nothing was sent to it.",
"server_invalid": "This server address isn't valid."
}
}
+9
View File
@@ -6451,5 +6451,14 @@
"next_issue": "Siguiente error de ortografía o gramática",
"previous_issue": "Anterior error de ortografía o gramática"
}
},
"desktop_sign_in": {
"browser_button": "Iniciar sesión con el navegador",
"browser_waiting": "Termina de iniciar sesión en el navegador",
"browser_unavailable": "Este servidor no permite iniciar sesión con el navegador. Usa tu contraseña.",
"browser_failed": "No se pudo iniciar sesión con el navegador. Inténtalo de nuevo.",
"server_needed": "Primero escribe la dirección del servidor.",
"server_declined": "No permitiste este servidor, así que no se le envió nada.",
"server_invalid": "Esta dirección de servidor no es válida."
}
}
+9
View File
@@ -6451,5 +6451,14 @@
"next_issue": "Järgmine õigekirja- või grammatikaviga",
"previous_issue": "Eelmine õigekirja- või grammatikaviga"
}
},
"desktop_sign_in": {
"browser_button": "Logi sisse brauseriga",
"browser_waiting": "Lõpeta sisselogimine brauseris",
"browser_unavailable": "See server ei paku brauseri kaudu sisselogimist. Kasuta parooli.",
"browser_failed": "Brauseri kaudu sisselogimine ei õnnestunud. Proovi uuesti.",
"server_needed": "Sisesta kõigepealt serveri aadress.",
"server_declined": "Sa ei lubanud seda serverit, nii et sellele ei saadetud midagi.",
"server_invalid": "See serveri aadress ei kehti."
}
}
+9
View File
@@ -6451,5 +6451,14 @@
"next_issue": "خطای املایی یا دستوری بعدی",
"previous_issue": "خطای املایی یا دستوری قبلی"
}
},
"desktop_sign_in": {
"browser_button": "ورود با مرورگر",
"browser_waiting": "ورود را در مرورگر کامل کنید",
"browser_unavailable": "این سرور ورود از طریق مرورگر را ارائه نمی‌دهد. از گذرواژه خود استفاده کنید.",
"browser_failed": "ورود از طریق مرورگر انجام نشد. دوباره تلاش کنید.",
"server_needed": "ابتدا نشانی سرور را وارد کنید.",
"server_declined": "شما این سرور را مجاز نکردید، پس چیزی به آن فرستاده نشد.",
"server_invalid": "این نشانی سرور معتبر نیست."
}
}
+9
View File
@@ -6451,5 +6451,14 @@
"next_issue": "Faute d'orthographe ou de grammaire suivante",
"previous_issue": "Faute d'orthographe ou de grammaire précédente"
}
},
"desktop_sign_in": {
"browser_button": "Se connecter avec le navigateur",
"browser_waiting": "Terminez la connexion dans votre navigateur",
"browser_unavailable": "Ce serveur ne propose pas la connexion par navigateur. Utilisez votre mot de passe.",
"browser_failed": "La connexion par navigateur a échoué. Réessayez.",
"server_needed": "Saisissez d'abord l'adresse du serveur.",
"server_declined": "Vous n'avez pas autorisé ce serveur, donc rien ne lui a été envoyé.",
"server_invalid": "Cette adresse de serveur n'est pas valide."
}
}
+9
View File
@@ -6452,5 +6452,14 @@
"next_issue": "שגיאת האיות או הדקדוק הבאה",
"previous_issue": "שגיאת האיות או הדקדוק הקודמת"
}
},
"desktop_sign_in": {
"browser_button": "כניסה באמצעות הדפדפן",
"browser_waiting": "יש להשלים את הכניסה בדפדפן",
"browser_unavailable": "השרת הזה לא מאפשר כניסה דרך דפדפן. יש להשתמש בסיסמה.",
"browser_failed": "הכניסה דרך הדפדפן לא הצליחה. כדאי לנסות שוב.",
"server_needed": "קודם צריך להזין את כתובת השרת.",
"server_declined": "לא אישרת את השרת הזה, לכן לא נשלח אליו דבר.",
"server_invalid": "כתובת השרת הזו לא תקינה."
}
}
+9
View File
@@ -6451,5 +6451,14 @@
"next_issue": "Következő helyesírási vagy nyelvtani hiba",
"previous_issue": "Előző helyesírási vagy nyelvtani hiba"
}
},
"desktop_sign_in": {
"browser_button": "Bejelentkezés böngészővel",
"browser_waiting": "Fejezze be a bejelentkezést a böngészőben",
"browser_unavailable": "Ez a szerver nem kínál böngészős bejelentkezést. Használja a jelszavát.",
"browser_failed": "A böngészős bejelentkezés nem sikerült. Próbálja újra.",
"server_needed": "Először adja meg a szerver címét.",
"server_declined": "Nem engedélyezte ezt a szervert, ezért semmit sem küldtünk neki.",
"server_invalid": "Ez a szervercím nem érvényes."
}
}
+9
View File
@@ -6451,5 +6451,14 @@
"next_issue": "Errore di ortografia o grammatica successivo",
"previous_issue": "Errore di ortografia o grammatica precedente"
}
},
"desktop_sign_in": {
"browser_button": "Accedi con il browser",
"browser_waiting": "Completa l'accesso nel browser",
"browser_unavailable": "Questo server non offre l'accesso tramite browser. Usa la password.",
"browser_failed": "L'accesso tramite browser non è riuscito. Riprova.",
"server_needed": "Inserisci prima l'indirizzo del server.",
"server_declined": "Non hai autorizzato questo server, quindi non gli è stato inviato nulla.",
"server_invalid": "Questo indirizzo del server non è valido."
}
}
+9
View File
@@ -6451,5 +6451,14 @@
"next_issue": "次のスペルまたは文法の問題",
"previous_issue": "前のスペルまたは文法の問題"
}
},
"desktop_sign_in": {
"browser_button": "ブラウザでサインイン",
"browser_waiting": "ブラウザでサインインを完了してください",
"browser_unavailable": "このサーバーはブラウザでのサインインに対応していません。パスワードを使用してください。",
"browser_failed": "ブラウザでのサインインに失敗しました。もう一度お試しください。",
"server_needed": "先にサーバーのアドレスを入力してください。",
"server_declined": "このサーバーを許可しなかったため、何も送信していません。",
"server_invalid": "このサーバーアドレスは正しくありません。"
}
}
+9
View File
@@ -6451,5 +6451,14 @@
"next_issue": "다음 맞춤법 또는 문법 문제",
"previous_issue": "이전 맞춤법 또는 문법 문제"
}
},
"desktop_sign_in": {
"browser_button": "브라우저로 로그인",
"browser_waiting": "브라우저에서 로그인을 마치세요",
"browser_unavailable": "이 서버는 브라우저 로그인을 지원하지 않습니다. 비밀번호를 사용하세요.",
"browser_failed": "브라우저 로그인에 실패했습니다. 다시 시도하세요.",
"server_needed": "먼저 서버 주소를 입력하세요.",
"server_declined": "이 서버를 허용하지 않아 아무것도 보내지 않았습니다.",
"server_invalid": "이 서버 주소는 올바르지 않습니다."
}
}
+9
View File
@@ -6451,5 +6451,14 @@
"next_issue": "Nākamā pareizrakstības vai gramatikas kļūda",
"previous_issue": "Iepriekšējā pareizrakstības vai gramatikas kļūda"
}
},
"desktop_sign_in": {
"browser_button": "Pierakstīties ar pārlūku",
"browser_waiting": "Pabeidziet pierakstīšanos pārlūkā",
"browser_unavailable": "Šis serveris nepiedāvā pierakstīšanos ar pārlūku. Izmantojiet paroli.",
"browser_failed": "Pierakstīšanās ar pārlūku neizdevās. Mēģiniet vēlreiz.",
"server_needed": "Vispirms ievadiet servera adresi.",
"server_declined": "Jūs neatļāvāt šo serveri, tāpēc tam nekas netika nosūtīts.",
"server_invalid": "Šī servera adrese nav derīga."
}
}
+9
View File
@@ -6451,5 +6451,14 @@
"next_issue": "Дараагийн зөв бичих эсвэл хэл зүйн алдаа",
"previous_issue": "Өмнөх зөв бичих эсвэл хэл зүйн алдаа"
}
},
"desktop_sign_in": {
"browser_button": "Хөтчөөр нэвтрэх",
"browser_waiting": "Нэвтрэлтээ хөтөч дээр дуусгана уу",
"browser_unavailable": "Энэ сервер хөтчөөр нэвтрэх боломжгүй. Нууц үгээ ашиглана уу.",
"browser_failed": "Хөтчөөр нэвтэрч чадсангүй. Дахин оролдоно уу.",
"server_needed": "Эхлээд серверийн хаягаа оруулна уу.",
"server_declined": "Та энэ серверийг зөвшөөрөөгүй тул юу ч илгээгээгүй.",
"server_invalid": "Энэ серверийн хаяг буруу байна."
}
}
+9
View File
@@ -6451,5 +6451,14 @@
"next_issue": "Neste stave- eller grammatikkfeil",
"previous_issue": "Forrige stave- eller grammatikkfeil"
}
},
"desktop_sign_in": {
"browser_button": "Logg på med nettleseren",
"browser_waiting": "Fullfør påloggingen i nettleseren",
"browser_unavailable": "Denne serveren tilbyr ikke pålogging via nettleser. Bruk passordet ditt.",
"browser_failed": "Pålogging via nettleseren mislyktes. Prøv igjen.",
"server_needed": "Skriv inn serveradressen først.",
"server_declined": "Du tillot ikke denne serveren, så ingenting ble sendt til den.",
"server_invalid": "Denne serveradressen er ikke gyldig."
}
}
+9
View File
@@ -6451,5 +6451,14 @@
"next_issue": "Volgende spel- of grammaticafout",
"previous_issue": "Vorige spel- of grammaticafout"
}
},
"desktop_sign_in": {
"browser_button": "Inloggen met je browser",
"browser_waiting": "Rond het inloggen af in je browser",
"browser_unavailable": "Deze server biedt geen inloggen via een browser. Gebruik je wachtwoord.",
"browser_failed": "Inloggen via de browser is niet gelukt. Probeer het opnieuw.",
"server_needed": "Vul eerst het adres van de server in.",
"server_declined": "Je hebt deze server niet toegestaan, dus er is niets naartoe gestuurd.",
"server_invalid": "Dit serveradres is niet geldig."
}
}
+9
View File
@@ -6451,5 +6451,14 @@
"next_issue": "Następny błąd pisowni lub gramatyki",
"previous_issue": "Poprzedni błąd pisowni lub gramatyki"
}
},
"desktop_sign_in": {
"browser_button": "Zaloguj się w przeglądarce",
"browser_waiting": "Dokończ logowanie w przeglądarce",
"browser_unavailable": "Ten serwer nie oferuje logowania przez przeglądarkę. Użyj hasła.",
"browser_failed": "Logowanie przez przeglądarkę nie powiodło się. Spróbuj ponownie.",
"server_needed": "Najpierw wpisz adres serwera.",
"server_declined": "Ten serwer nie został dozwolony, więc nic do niego nie wysłano.",
"server_invalid": "Ten adres serwera jest nieprawidłowy."
}
}
+9
View File
@@ -6451,5 +6451,14 @@
"next_issue": "Próximo erro de ortografia ou gramática",
"previous_issue": "Erro de ortografia ou gramática anterior"
}
},
"desktop_sign_in": {
"browser_button": "Entrar com o navegador",
"browser_waiting": "Termine de entrar no navegador",
"browser_unavailable": "Este servidor não oferece entrada pelo navegador. Use sua senha.",
"browser_failed": "Não foi possível entrar pelo navegador. Tente novamente.",
"server_needed": "Digite primeiro o endereço do servidor.",
"server_declined": "Você não permitiu este servidor, então nada foi enviado a ele.",
"server_invalid": "Este endereço de servidor não é válido."
}
}
+9
View File
@@ -6451,5 +6451,14 @@
"next_issue": "Următoarea greșeală de ortografie sau gramatică",
"previous_issue": "Greșeala de ortografie sau gramatică anterioară"
}
},
"desktop_sign_in": {
"browser_button": "Conectează-te cu browserul",
"browser_waiting": "Finalizează conectarea în browser",
"browser_unavailable": "Acest server nu oferă conectare prin browser. Folosește parola.",
"browser_failed": "Conectarea prin browser nu a reușit. Încearcă din nou.",
"server_needed": "Introdu mai întâi adresa serverului.",
"server_declined": "Nu ai permis acest server, așa că nu i s-a trimis nimic.",
"server_invalid": "Această adresă de server nu este validă."
}
}
+9
View File
@@ -6451,5 +6451,14 @@
"next_issue": "Следующая орфографическая или грамматическая ошибка",
"previous_issue": "Предыдущая орфографическая или грамматическая ошибка"
}
},
"desktop_sign_in": {
"browser_button": "Войти через браузер",
"browser_waiting": "Завершите вход в браузере",
"browser_unavailable": "Этот сервер не поддерживает вход через браузер. Используйте пароль.",
"browser_failed": "Не удалось войти через браузер. Попробуйте ещё раз.",
"server_needed": "Сначала введите адрес сервера.",
"server_declined": "Вы не разрешили этот сервер, поэтому ему ничего не отправлено.",
"server_invalid": "Этот адрес сервера недействителен."
}
}
+9
View File
@@ -6451,5 +6451,14 @@
"next_issue": "Ďalšia pravopisná alebo gramatická chyba",
"previous_issue": "Predchádzajúca pravopisná alebo gramatická chyba"
}
},
"desktop_sign_in": {
"browser_button": "Prihlásiť sa v prehliadači",
"browser_waiting": "Dokončite prihlásenie v prehliadači",
"browser_unavailable": "Tento server neponúka prihlásenie cez prehliadač. Použite heslo.",
"browser_failed": "Prihlásenie cez prehliadač sa nepodarilo. Skúste to znova.",
"server_needed": "Najprv zadajte adresu servera.",
"server_declined": "Tento server ste nepovolili, preto sa naň nič neodoslalo.",
"server_invalid": "Táto adresa servera nie je platná."
}
}
+9
View File
@@ -6459,5 +6459,14 @@
"next_issue": "ปัญหาการสะกดหรือไวยากรณ์ถัดไป",
"previous_issue": "ปัญหาการสะกดหรือไวยากรณ์ก่อนหน้า"
}
},
"desktop_sign_in": {
"browser_button": "ลงชื่อเข้าใช้ด้วยเบราว์เซอร์",
"browser_waiting": "ลงชื่อเข้าใช้ให้เสร็จในเบราว์เซอร์",
"browser_unavailable": "เซิร์ฟเวอร์นี้ไม่รองรับการลงชื่อเข้าใช้ผ่านเบราว์เซอร์ โปรดใช้รหัสผ่าน",
"browser_failed": "ลงชื่อเข้าใช้ผ่านเบราว์เซอร์ไม่สำเร็จ โปรดลองอีกครั้ง",
"server_needed": "ป้อนที่อยู่เซิร์ฟเวอร์ก่อน",
"server_declined": "คุณไม่ได้อนุญาตเซิร์ฟเวอร์นี้ จึงไม่มีการส่งข้อมูลใดไป",
"server_invalid": "ที่อยู่เซิร์ฟเวอร์นี้ไม่ถูกต้อง"
}
}
+9
View File
@@ -6451,5 +6451,14 @@
"next_issue": "Sonraki yazım veya dil bilgisi hatası",
"previous_issue": "Önceki yazım veya dil bilgisi hatası"
}
},
"desktop_sign_in": {
"browser_button": "Tarayıcıyla oturum aç",
"browser_waiting": "Oturum açmayı tarayıcınızda tamamlayın",
"browser_unavailable": "Bu sunucu tarayıcıyla oturum açmayı desteklemiyor. Parolanızı kullanın.",
"browser_failed": "Tarayıcıyla oturum açılamadı. Yeniden deneyin.",
"server_needed": "Önce sunucu adresini girin.",
"server_declined": "Bu sunucuya izin vermediniz, bu yüzden ona hiçbir şey gönderilmedi.",
"server_invalid": "Bu sunucu adresi geçerli değil."
}
}
+9
View File
@@ -6451,5 +6451,14 @@
"next_issue": "Наступна орфографічна або граматична помилка",
"previous_issue": "Попередня орфографічна або граматична помилка"
}
},
"desktop_sign_in": {
"browser_button": "Увійти через браузер",
"browser_waiting": "Завершіть вхід у браузері",
"browser_unavailable": "Цей сервер не підтримує вхід через браузер. Використайте пароль.",
"browser_failed": "Не вдалося увійти через браузер. Спробуйте ще раз.",
"server_needed": "Спочатку введіть адресу сервера.",
"server_declined": "Ви не дозволили цей сервер, тому йому нічого не надіслано.",
"server_invalid": "Ця адреса сервера недійсна."
}
}
+9
View File
@@ -6463,5 +6463,14 @@
"next_issue": "Lỗi chính tả hoặc ngữ pháp tiếp theo",
"previous_issue": "Lỗi chính tả hoặc ngữ pháp trước đó"
}
},
"desktop_sign_in": {
"browser_button": "Đăng nhập bằng trình duyệt",
"browser_waiting": "Hoàn tất đăng nhập trong trình duyệt",
"browser_unavailable": "Máy chủ này không hỗ trợ đăng nhập qua trình duyệt. Hãy dùng mật khẩu.",
"browser_failed": "Không thể đăng nhập qua trình duyệt. Hãy thử lại.",
"server_needed": "Trước tiên hãy nhập địa chỉ máy chủ.",
"server_declined": "Bạn không cho phép máy chủ này nên không có gì được gửi đến đó.",
"server_invalid": "Địa chỉ máy chủ này không hợp lệ."
}
}
+9
View File
@@ -6451,5 +6451,14 @@
"next_issue": "下一個拼字或文法問題",
"previous_issue": "上一個拼字或文法問題"
}
},
"desktop_sign_in": {
"browser_button": "使用瀏覽器登入",
"browser_waiting": "請在瀏覽器中完成登入",
"browser_unavailable": "此伺服器不支援透過瀏覽器登入。請使用密碼。",
"browser_failed": "無法透過瀏覽器登入。請再試一次。",
"server_needed": "請先輸入伺服器位址。",
"server_declined": "你沒有允許此伺服器,因此沒有傳送任何內容給它。",
"server_invalid": "此伺服器位址無效。"
}
}
+9
View File
@@ -6451,5 +6451,14 @@
"next_issue": "下一个拼写或语法问题",
"previous_issue": "上一个拼写或语法问题"
}
},
"desktop_sign_in": {
"browser_button": "使用浏览器登录",
"browser_waiting": "请在浏览器中完成登录",
"browser_unavailable": "此服务器不支持通过浏览器登录。请使用密码。",
"browser_failed": "无法通过浏览器登录。请重试。",
"server_needed": "请先输入服务器地址。",
"server_declined": "你没有允许此服务器,因此没有向它发送任何内容。",
"server_invalid": "此服务器地址无效。"
}
}
+13
View File
@@ -16,6 +16,9 @@ import { debug } from '@/lib/debug';
import { setServerAuthIssue } from '@/lib/server-auth-status';
import { generateAccountId, MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
import { replaceWindowLocation, getPathPrefix, getLocaleFromPath, apiFetch } from '@/lib/browser-navigation';
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
import { getDesktopBridge } from '@/lib/desktop/bridge';
import { DESKTOP_CLIENT_ID_KEY } from '@/lib/desktop/browser-sign-in';
import { isEmbedded, notifyParent } from '@/lib/iframe-bridge';
import { snapshotAccount, restoreAccount, clearAllStores, evictAccount, evictAll, forgetAccountRows } from '@/lib/account-state-manager';
import { clearAllPluginStorage, clearPluginStorageForAccount } from '@/lib/plugin-sandbox/storage-scope';
@@ -470,6 +473,8 @@ async function exchangeOAuthCode(params: {
slot,
...(serverId ? { server_id: serverId } : {}),
remember,
// The desktop app signs in to a server only it knows about.
...(IS_DESKTOP_BUILD ? { custom_server_url: serverUrl, client_id: sessionStorage.getItem(DESKTOP_CLIENT_ID_KEY) } : {}),
}),
}).then((res) => noteSessionAnswer(res, slot));
}
@@ -1026,6 +1031,14 @@ function finishSignOut(endSessionUrl: string | null, userInitiated: boolean): vo
}
}
if (endSessionUrl) {
// The desktop build signs in through the system browser, so the provider's
// session lives there. The window itself goes straight to the login page.
const desktopShell = IS_DESKTOP_BUILD ? getDesktopBridge()?.shell : undefined;
if (desktopShell) {
desktopShell.openExternal(endSessionUrl);
navigateToLogin();
return;
}
leavingForProviderLogout = true;
replaceWindowLocation(endSessionUrl);
return;