mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-05 00:14:18 +00:00
Merge branch 'feat/do-browser-signin' into feat/desktop-offline
This commit is contained in:
@@ -366,6 +366,9 @@ LOGIN_WEBSITE_URL=https://bulwarkmail.org
|
||||
# that authenticate API tokens as Bearer tokens instead of passwords, such
|
||||
# as Fastmail (JMAP_SERVER_URL=https://api.fastmail.com).
|
||||
# LOGIN_SHOW_TOKEN_LOGIN=true
|
||||
# The desktop app's "Sign in with your browser" through this login page. On by default.
|
||||
# Turn it off on a terminal server: another user there could send a link asking for a sign-in.
|
||||
# DESKTOP_BROWSER_SIGN_IN=false
|
||||
#
|
||||
# Show the "Remember me" box (default false). Without it every sign-in is
|
||||
# remembered, provided SESSION_SECRET is set. The box also decides for SSO
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"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";
|
||||
@@ -12,8 +10,8 @@ import { LITE_OAUTH_AVAILABLE, getLiteOAuthRedirectUri } from "@/lib/auth/lite-o
|
||||
import { AlertCircle } from "@/components/icons";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useParams } from "next/navigation";
|
||||
import { buildMobileHandoffUrl, mobileHandoffTarget } from "@/lib/auth/mobile-handoff";
|
||||
import { confirmMobileHandoffCodeUrl } from "@/lib/auth/mobile-handoff-client";
|
||||
import { buildMobileHandoffUrl, desktopHandoffDeclineUrl, desktopHandoffPort, desktopHandoffTarget, handoffTarget } from "@/lib/auth/mobile-handoff";
|
||||
import { confirmMobileHandoffCodeUrl, discardDesktopSignIn } from "@/lib/auth/mobile-handoff-client";
|
||||
import { MobileHandoffConsent } from "@/components/auth/mobile-handoff-consent";
|
||||
import { MailSkeleton } from "@/components/boot-skeleton/mail-skeleton";
|
||||
import { useSkeletonLabels } from "@/components/boot-skeleton/use-skeleton-labels";
|
||||
@@ -32,7 +30,21 @@ function OAuthCallbackInner() {
|
||||
url: string | null;
|
||||
resolveUrl?: () => Promise<string | null>;
|
||||
username: string;
|
||||
/** The desktop app's: what ends it, if the user says no or leaves. */
|
||||
desktop?: { code: string; target: string; state: string };
|
||||
} | null>(null);
|
||||
// Unanswered, and the tab goes: the tokens the server parked go too. A yes has left this
|
||||
// to the app, and "Not now" has already said.
|
||||
const desktopPending = pendingHandoff?.desktop;
|
||||
const desktopAnswered = useRef(false);
|
||||
useEffect(() => {
|
||||
if (!desktopPending) return;
|
||||
const leave = () => {
|
||||
if (!desktopAnswered.current) discardDesktopSignIn(desktopPending.code);
|
||||
};
|
||||
window.addEventListener("pagehide", leave);
|
||||
return () => window.removeEventListener("pagehide", leave);
|
||||
}, [desktopPending]);
|
||||
// The authorization code is single-use. React's development double effect
|
||||
// ran this twice: the second pass no longer saw the pairing re-auth flag
|
||||
// (the first had removed it) and spent the code on the login flow instead.
|
||||
@@ -118,12 +130,9 @@ 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.
|
||||
// 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
|
||||
const redirectUri = 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) => {
|
||||
@@ -133,10 +142,6 @@ 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');
|
||||
@@ -160,7 +165,7 @@ function OAuthCallbackInner() {
|
||||
let mobileRedirectUri = "";
|
||||
let mobileState: string | null = null;
|
||||
try {
|
||||
mobileRedirectUri = mobileHandoffTarget(sessionStorage.getItem("mobile_redirect_uri"));
|
||||
mobileRedirectUri = handoffTarget(sessionStorage.getItem("mobile_redirect_uri"));
|
||||
mobileState = sessionStorage.getItem("mobile_state");
|
||||
} catch { /* sessionStorage may be unavailable */ }
|
||||
|
||||
@@ -193,14 +198,22 @@ function OAuthCallbackInner() {
|
||||
sessionStorage.removeItem("mobile_redirect_uri");
|
||||
sessionStorage.removeItem("mobile_state");
|
||||
} catch { /* ignore */ }
|
||||
const desktopTarget = desktopHandoffTarget(mobileRedirectUri);
|
||||
setPendingHandoff({
|
||||
url: null,
|
||||
resolveUrl: () => confirmMobileHandoffCodeUrl({
|
||||
target: mobileRedirectUri,
|
||||
code: handoffCode,
|
||||
state: handoffState,
|
||||
}),
|
||||
username: typeof data.server_url === "string" ? data.server_url : "",
|
||||
resolveUrl: () => {
|
||||
desktopAnswered.current = true;
|
||||
return confirmMobileHandoffCodeUrl({
|
||||
target: mobileRedirectUri,
|
||||
code: handoffCode,
|
||||
state: handoffState,
|
||||
});
|
||||
},
|
||||
...(desktopTarget ? { desktop: { code: handoffCode, target: desktopTarget, state: handoffState } } : {}),
|
||||
// Who is being handed over, where the server could say. Its address otherwise.
|
||||
username: desktopTarget && typeof data.account === "string" && data.account
|
||||
? data.account
|
||||
: typeof data.server_url === "string" ? data.server_url : "",
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -275,12 +288,22 @@ function OAuthCallbackInner() {
|
||||
url={pendingHandoff.url}
|
||||
resolveUrl={pendingHandoff.resolveUrl ?? null}
|
||||
username={pendingHandoff.username}
|
||||
desktopPort={pendingHandoff.desktop ? desktopHandoffPort(pendingHandoff.desktop.target) : undefined}
|
||||
onError={() => {
|
||||
setPendingHandoff(null);
|
||||
setError("token_exchange_failed");
|
||||
}}
|
||||
onCancel={() => {
|
||||
const desktop = pendingHandoff.desktop;
|
||||
setPendingHandoff(null);
|
||||
if (desktop) {
|
||||
// Ends the flow here and at the app. A plain login page from here would be a way
|
||||
// to get a browser session nobody asked for.
|
||||
desktopAnswered.current = true;
|
||||
discardDesktopSignIn(desktop.code);
|
||||
window.location.replace(desktopHandoffDeclineUrl(desktop.target, desktop.state));
|
||||
return;
|
||||
}
|
||||
router.push(toRouterPath(`${getPathPrefix(params.locale as string)}/${params.locale}/login`));
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -6,9 +6,11 @@ import { EMPTY_POLICY_LAYERS, resolvePolicies } from '@/lib/desktop/policy-schem
|
||||
|
||||
// The device's OS policy on the two ways into SSO: nothing starts towards a refused server.
|
||||
|
||||
const params = vi.hoisted(() => ({ value: null as URLSearchParams | null }));
|
||||
|
||||
vi.mock('next/navigation', () => ({
|
||||
useParams: () => ({ locale: 'en' }),
|
||||
useSearchParams: () => new URLSearchParams(),
|
||||
useSearchParams: () => params.value ?? new URLSearchParams(),
|
||||
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }),
|
||||
usePathname: () => '/login',
|
||||
}));
|
||||
@@ -30,6 +32,7 @@ vi.mock('@/hooks/use-config', () => ({
|
||||
oauthEnabled: true,
|
||||
oauthOnly: true,
|
||||
oauthClientId: 'webmail',
|
||||
desktopBrowserSignIn: true,
|
||||
...configOverrides.value,
|
||||
}),
|
||||
}));
|
||||
@@ -56,6 +59,7 @@ let fetched: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
configOverrides.value = {};
|
||||
params.value = null;
|
||||
fetched = [];
|
||||
sessionStorage.clear();
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
|
||||
@@ -110,3 +114,243 @@ describe('automatic SSO', () => {
|
||||
await waitFor(() => expect(startedSso()).toBe(true), { timeout: 10000 });
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe('sign-in with the browser, in the app', () => {
|
||||
const REDIRECT = 'http://127.0.0.1:51234/bulwark-desktop/callback';
|
||||
|
||||
function appBridge(options: { servers?: string[]; browserSignIn?: boolean; complete?: () => unknown } = {}) {
|
||||
const forced: Record<string, unknown> = {};
|
||||
if (options.servers) forced.AllowedJmapServers = options.servers;
|
||||
if (options.browserSignIn !== undefined) forced.BrowserSignIn = options.browserSignIn;
|
||||
const snapshot = resolvePolicies({ ...EMPTY_POLICY_LAYERS, forcedMachine: forced });
|
||||
const controls = {
|
||||
begin: vi.fn(async () => ({ ok: true, flowId: 'f1', redirectUri: REDIRECT, state: 'main-state' })),
|
||||
complete: vi.fn(async () => (options.complete ? options.complete() : { ok: false, error: 'cancelled' })),
|
||||
cancel: vi.fn(),
|
||||
};
|
||||
window.bulwarkDesktop = {
|
||||
version: 1,
|
||||
platform: 'win32',
|
||||
mode: 'instance',
|
||||
auth: { claim: () => controls },
|
||||
policy: { get: () => snapshot, onChange: () => () => {} },
|
||||
} as unknown as BulwarkDesktopBridge;
|
||||
return controls;
|
||||
}
|
||||
|
||||
const browserButton = () => screen.findByTestId('desktop-browser-sign-in', {}, { timeout: 10000 });
|
||||
|
||||
it('stays the other choice next to the form', async () => {
|
||||
configOverrides.value = { oauthEnabled: false, oauthOnly: false };
|
||||
appBridge();
|
||||
render(<LoginPage />);
|
||||
expect((await browserButton()).textContent).toContain('button');
|
||||
expect(document.getElementById('username')).not.toBeNull();
|
||||
expect(document.getElementById('password')).not.toBeNull();
|
||||
}, 30_000);
|
||||
|
||||
it('is the main way in on an OAuth-only instance, with no form', async () => {
|
||||
appBridge();
|
||||
render(<LoginPage />);
|
||||
await browserButton();
|
||||
expect(document.getElementById('password')).toBeNull();
|
||||
expect(await ssoButton()).toBeInTheDocument();
|
||||
}, 30_000);
|
||||
|
||||
it('is gone when a policy says so, and the form stays', async () => {
|
||||
configOverrides.value = { oauthEnabled: false, oauthOnly: false };
|
||||
appBridge({ browserSignIn: false });
|
||||
render(<LoginPage />);
|
||||
await waitFor(() => expect(document.getElementById('password')).not.toBeNull(), { timeout: 10000 });
|
||||
expect(screen.queryByTestId('desktop-browser-sign-in')).toBeNull();
|
||||
}, 30_000);
|
||||
|
||||
it('is gone when the instance\'s admin turned it off', async () => {
|
||||
configOverrides.value = { oauthEnabled: false, oauthOnly: false, desktopBrowserSignIn: false };
|
||||
appBridge();
|
||||
render(<LoginPage />);
|
||||
await waitFor(() => expect(document.getElementById('password')).not.toBeNull(), { timeout: 10000 });
|
||||
expect(screen.queryByTestId('desktop-browser-sign-in')).toBeNull();
|
||||
}, 30_000);
|
||||
|
||||
it('is not there in a browser', async () => {
|
||||
configOverrides.value = { oauthEnabled: false, oauthOnly: false };
|
||||
render(<LoginPage />);
|
||||
await waitFor(() => expect(document.getElementById('password')).not.toBeNull(), { timeout: 10000 });
|
||||
expect(screen.queryByTestId('desktop-browser-sign-in')).toBeNull();
|
||||
}, 30_000);
|
||||
|
||||
it('starts nothing towards a server off the list, and says why', async () => {
|
||||
const controls = appBridge({ servers: ['https://other.example.org'] });
|
||||
render(<LoginPage />);
|
||||
fireEvent.click(await browserButton());
|
||||
expect(await screen.findByText('server_refused')).toBeInTheDocument();
|
||||
expect(controls.begin).not.toHaveBeenCalled();
|
||||
}, 30_000);
|
||||
|
||||
it('a flow that ran out says so, and the button comes back', async () => {
|
||||
const controls = appBridge({ complete: () => ({ ok: false, error: 'timeout' }) });
|
||||
render(<LoginPage />);
|
||||
fireEvent.click(await browserButton());
|
||||
expect(await screen.findByText('timed_out')).toBeInTheDocument();
|
||||
expect(controls.complete).toHaveBeenCalledOnce();
|
||||
expect(await browserButton()).not.toBeDisabled();
|
||||
}, 30_000);
|
||||
|
||||
it('a forced server says no to a server of the user\'s own, in what it asks the instance', async () => {
|
||||
const snapshot = resolvePolicies({ ...EMPTY_POLICY_LAYERS, forcedMachine: { InstanceUrl: 'https://bulwark.example.org' } });
|
||||
const controls = appBridge({ complete: () => ({ ok: true, code: 'c'.repeat(64), state: 'main-state' }) });
|
||||
window.bulwarkDesktop = {
|
||||
version: 1,
|
||||
platform: 'win32',
|
||||
mode: 'instance',
|
||||
auth: { claim: () => controls },
|
||||
policy: { get: () => snapshot, onChange: () => () => {} },
|
||||
} as unknown as BulwarkDesktopBridge;
|
||||
let sent: Record<string, unknown> | null = null;
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
if (url.includes('/api/auth/oauth/metadata')) return new Response(JSON.stringify(METADATA), { status: 200 });
|
||||
if (url.includes('/api/auth/desktop-handoff/redeem')) {
|
||||
sent = JSON.parse(String(init?.body));
|
||||
return new Response(JSON.stringify({ error: 'blocked_by_policy' }), { status: 403 });
|
||||
}
|
||||
return new Response('{}', { status: 200 });
|
||||
}));
|
||||
render(<LoginPage />);
|
||||
fireEvent.click(await browserButton());
|
||||
await waitFor(() => expect(sent).toMatchObject({ custom_servers_allowed: false }));
|
||||
}, 30_000);
|
||||
|
||||
it('asks the instance with the device\'s list, and a refusal is the policy\'s notice', async () => {
|
||||
appBridge({ servers: ['https://mail.example.org'], complete: () => ({ ok: true, code: 'c'.repeat(64), state: 'main-state' }) });
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
fetched.push(url);
|
||||
if (url.includes('/api/auth/oauth/metadata')) return new Response(JSON.stringify(METADATA), { status: 200 });
|
||||
if (url.includes('/api/auth/desktop-handoff/redeem')) {
|
||||
// The same rule as the field for a server of one's own, on an instance with a server of its own.
|
||||
expect(JSON.parse(String(init?.body))).toMatchObject({ allowed_servers: ['https://mail.example.org'], custom_servers_allowed: true });
|
||||
return new Response(JSON.stringify({ error: 'blocked_by_policy' }), { status: 403 });
|
||||
}
|
||||
return new Response('{}', { status: 200 });
|
||||
}));
|
||||
render(<LoginPage />);
|
||||
fireEvent.click(await browserButton());
|
||||
expect(await screen.findByText('server_refused')).toBeInTheDocument();
|
||||
expect(fetched.some((url) => url.includes('/api/auth/desktop-handoff/redeem'))).toBe(true);
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
describe('the page the app opens in the browser', () => {
|
||||
const CHALLENGE = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
|
||||
const search = (entries: Record<string, string>) => new URLSearchParams(entries);
|
||||
|
||||
function handoffPage(checkAnswer: () => Response = () => new Response('{"ok":true}', { status: 200 }), parkAnswer: () => Response = () => new Response(JSON.stringify({ code: 'a'.repeat(64) }), { status: 200 })) {
|
||||
configOverrides.value = { oauthEnabled: false, oauthOnly: false, loginShowTotp: true };
|
||||
params.value = search({ mobile_redirect_uri: 'http://127.0.0.1:51234/bulwark-desktop/callback', mobile_state: 's', mobile_code_challenge: CHALLENGE });
|
||||
const parks: Record<string, unknown>[] = [];
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = String(input);
|
||||
fetched.push(url);
|
||||
if (url.includes('/api/auth/desktop-handoff/check')) return checkAnswer();
|
||||
if (url.includes('/api/auth/desktop-handoff/park')) {
|
||||
parks.push(JSON.parse(String(init?.body)));
|
||||
return parkAnswer();
|
||||
}
|
||||
return new Response('{}', { status: 200 });
|
||||
}));
|
||||
return parks;
|
||||
}
|
||||
|
||||
async function submit(totp?: string) {
|
||||
render(<LoginPage />);
|
||||
await screen.findByTestId('desktop-handoff-banner', {}, { timeout: 10000 });
|
||||
fireEvent.change(document.getElementById('username')!, { target: { value: 'a@example.org' } });
|
||||
fireEvent.change(document.getElementById('password')!, { target: { value: 'secret' } });
|
||||
if (totp) {
|
||||
fireEvent.click(screen.getByText('totp_toggle'));
|
||||
fireEvent.change(await screen.findByLabelText('totp_label'), { target: { value: totp } });
|
||||
}
|
||||
fireEvent.submit(document.getElementById('password')!.closest('form')!);
|
||||
}
|
||||
|
||||
it('holds the password until the yes: checked first, parked only then, with no browser session', async () => {
|
||||
const parks = handoffPage();
|
||||
await submit();
|
||||
expect(await screen.findByText('consent_title')).toBeInTheDocument();
|
||||
expect(parks).toEqual([]);
|
||||
expect(fetched.filter((url) => url.includes('/desktop-handoff/check'))).toHaveLength(1);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'consent_confirm' }));
|
||||
await waitFor(() => expect(parks).toHaveLength(1));
|
||||
expect(parks[0]).toMatchObject({ code_challenge: CHALLENGE, state: 's', username: 'a@example.org', password: 'secret' });
|
||||
// No session anywhere: nothing went to the routes a browser sign-in writes cookies from.
|
||||
expect(fetched.some((url) => /api\/auth\/(session|verify|token)/.test(url))).toBe(false);
|
||||
}, 30_000);
|
||||
|
||||
it('"Not now" parks nothing', async () => {
|
||||
const parks = handoffPage();
|
||||
await submit();
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'consent_cancel' }));
|
||||
await waitFor(() => expect(screen.queryByText('consent_title')).toBeNull());
|
||||
expect(parks).toEqual([]);
|
||||
}, 30_000);
|
||||
|
||||
it('a two-factor code is not spent before the yes, as it works once', async () => {
|
||||
const parks = handoffPage();
|
||||
await submit('123456');
|
||||
expect(await screen.findByText('consent_title')).toBeInTheDocument();
|
||||
expect(parks).toEqual([]);
|
||||
expect(fetched.some((url) => url.includes('/desktop-handoff/check'))).toBe(false);
|
||||
fireEvent.click(screen.getByRole('button', { name: 'consent_confirm' }));
|
||||
await waitFor(() => expect(parks).toHaveLength(1));
|
||||
expect(parks[0]).toMatchObject({ totp: '123456' });
|
||||
}, 30_000);
|
||||
|
||||
it('a wrong password stays on the form, with no question', async () => {
|
||||
const parks = handoffPage(() => new Response(JSON.stringify({ error: 'invalid_credentials' }), { status: 401 }));
|
||||
await submit();
|
||||
expect(await screen.findByText('error.invalid_credentials')).toBeInTheDocument();
|
||||
expect(screen.queryByText('consent_title')).toBeNull();
|
||||
expect(parks).toEqual([]);
|
||||
}, 30_000);
|
||||
|
||||
it('too many attempts says so, and not that something broke', async () => {
|
||||
handoffPage(() => new Response(JSON.stringify({ error: 'too_many_attempts' }), { status: 429 }));
|
||||
await submit();
|
||||
expect(await screen.findByText('too_many')).toBeInTheDocument();
|
||||
expect(screen.queryByText('error.server_error')).toBeNull();
|
||||
}, 30_000);
|
||||
|
||||
it('a failure at the yes goes back to the form and says why', async () => {
|
||||
handoffPage(undefined, () => new Response(JSON.stringify({ error: 'invalid_credentials' }), { status: 401 }));
|
||||
await submit('000000');
|
||||
fireEvent.click(await screen.findByRole('button', { name: 'consent_confirm' }));
|
||||
await waitFor(() => expect(screen.queryByText('consent_title')).toBeNull());
|
||||
expect(await screen.findByText(/totp_invalid/)).toBeInTheDocument();
|
||||
}, 30_000);
|
||||
|
||||
it('is the ordinary page when the instance\'s admin turned it off', async () => {
|
||||
configOverrides.value = { oauthEnabled: false, oauthOnly: false, desktopBrowserSignIn: false };
|
||||
params.value = search({ mobile_redirect_uri: 'http://127.0.0.1:51234/bulwark-desktop/callback', mobile_state: 's', mobile_code_challenge: CHALLENGE });
|
||||
render(<LoginPage />);
|
||||
await waitFor(() => expect(document.getElementById('password')).not.toBeNull(), { timeout: 10000 });
|
||||
expect(screen.queryByTestId('desktop-handoff-banner')).toBeNull();
|
||||
}, 30_000);
|
||||
|
||||
it('ignores a loopback target with no challenge, and is the ordinary page', async () => {
|
||||
configOverrides.value = { oauthEnabled: false, oauthOnly: false };
|
||||
params.value = search({ mobile_redirect_uri: 'http://127.0.0.1:51234/bulwark-desktop/callback', mobile_state: 's' });
|
||||
render(<LoginPage />);
|
||||
await waitFor(() => expect(document.getElementById('password')).not.toBeNull(), { timeout: 10000 });
|
||||
expect(screen.queryByTestId('desktop-handoff-banner')).toBeNull();
|
||||
}, 30_000);
|
||||
|
||||
it('ignores a target that is not this computer', async () => {
|
||||
configOverrides.value = { oauthEnabled: false, oauthOnly: false };
|
||||
params.value = search({ mobile_redirect_uri: 'http://evil.example:51234/bulwark-desktop/callback', mobile_state: 's', mobile_code_challenge: CHALLENGE });
|
||||
render(<LoginPage />);
|
||||
await waitFor(() => expect(document.getElementById('password')).not.toBeNull(), { timeout: 10000 });
|
||||
expect(screen.queryByTestId('desktop-handoff-banner')).toBeNull();
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
@@ -6,7 +6,7 @@ import { IS_DESKTOP_BUILD } from "@/lib/desktop/flag";
|
||||
import { forgetDesktopServer, getDesktopBridge } from "@/lib/desktop/bridge";
|
||||
import { customServerAllowedByDesktopPolicy, jmapServerAllowedByDesktopPolicy, useDesktopPolicy } from "@/lib/desktop/policy";
|
||||
import { accountRefusedByDesktopPolicy } from "@/lib/desktop/refused-accounts";
|
||||
import { browserSignInAvailable, runDesktopBrowserSignIn } from "@/lib/desktop/browser-sign-in";
|
||||
import { browserSignInAvailable, cancelBrowserSignIn, runDesktopBrowserSignIn, runInstanceBrowserSignIn, type DesktopBrowserSignInOutcome } from "@/lib/desktop/browser-sign-in";
|
||||
import { useRouter } from "@/i18n/navigation";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
@@ -43,8 +43,8 @@ import {
|
||||
} from "@/lib/auth/lite-oauth";
|
||||
import { toAsciiDomain } from "@/lib/idn";
|
||||
import { usePrefetchMailView } from "@/hooks/use-prefetch-mail-view";
|
||||
import { buildMobileHandoffUrl, mobileCodeChallenge, mobileHandoffTarget, signedInLoginAction } from "@/lib/auth/mobile-handoff";
|
||||
import { requestMobileHandoffCodeUrl } from "@/lib/auth/mobile-handoff-client";
|
||||
import { buildMobileHandoffCodeUrl, buildMobileHandoffUrl, desktopHandoffDeclineUrl, desktopHandoffPort, desktopHandoffTarget, mobileCodeChallenge, mobileHandoffTarget, signedInLoginAction } from "@/lib/auth/mobile-handoff";
|
||||
import { checkDesktopSignIn, parkDesktopSignIn, requestMobileHandoffCodeUrl } from "@/lib/auth/mobile-handoff-client";
|
||||
import { MobileHandoffConsent } from "@/components/auth/mobile-handoff-consent";
|
||||
import { HelpLinks } from "@/components/layout/help-links";
|
||||
import { LoginSkeleton } from "@/components/boot-skeleton/login-skeleton";
|
||||
@@ -194,11 +194,14 @@ function LoginPageContent() {
|
||||
const router = useRouter();
|
||||
const t = useTranslations("login");
|
||||
const tDesktop = useTranslations("desktop_sign_in");
|
||||
const tBrowser = useTranslations("desktop_browser_sign_in");
|
||||
const tDesktopPolicy = useTranslations("desktop_policy");
|
||||
const desktopPolicy = useDesktopPolicy();
|
||||
// Only the desktop build sets these; a browser never shows them.
|
||||
const [desktopNotice, setDesktopNotice] = useState<string | null>(null);
|
||||
const [desktopWaiting, setDesktopWaiting] = useState(false);
|
||||
// Leaving the page ends a sign-in it's waiting for: the verifier goes with it.
|
||||
useEffect(() => () => cancelBrowserSignIn(), []);
|
||||
const tSessionLimits = useTranslations("session_limits");
|
||||
const tOffline = useTranslations("offline");
|
||||
const tOfflineWipe = useTranslations("offlineWipe");
|
||||
@@ -230,9 +233,16 @@ function LoginPageContent() {
|
||||
// server to park the sign-in on and keeps handing over in the fragment.
|
||||
const rawMobileCodeChallenge = IS_LITE ? "" : searchParams.get("mobile_code_challenge") ?? "";
|
||||
const mobileChallenge = mobileCodeChallenge(rawMobileCodeChallenge);
|
||||
const mobileRedirectUri = rawMobileCodeChallenge && !mobileChallenge
|
||||
// The desktop app's loopback listener is the other target, only ever with a code and a
|
||||
// state, and the desktop build itself has no hand-off routes (lib/desktop/blocked-routes.ts).
|
||||
const { desktopBrowserSignIn: desktopSignInEnabled } = useConfig();
|
||||
const desktopTarget = !IS_DESKTOP_BUILD && desktopSignInEnabled && mobileChallenge && rawMobileState
|
||||
? desktopHandoffTarget(searchParams.get("mobile_redirect_uri"))
|
||||
: "";
|
||||
const isDesktopHandoff = Boolean(desktopTarget);
|
||||
const mobileRedirectUri = desktopTarget || (rawMobileCodeChallenge && !mobileChallenge
|
||||
? ""
|
||||
: mobileHandoffTarget(searchParams.get("mobile_redirect_uri"));
|
||||
: mobileHandoffTarget(searchParams.get("mobile_redirect_uri")));
|
||||
const mobileState = mobileRedirectUri ? rawMobileState : "";
|
||||
const isMobileHandoff = Boolean(mobileRedirectUri);
|
||||
const [pendingHandoff, setPendingHandoff] = useState<{
|
||||
@@ -392,7 +402,8 @@ function LoginPageContent() {
|
||||
const canRememberSession = rememberMeEnabled && (!IS_LITE || liteTokenLoginSupported !== false);
|
||||
// Sign-ins are remembered without asking unless the admin offers the box
|
||||
// (LOGIN_SHOW_REMEMBER_ME).
|
||||
const showRememberMe = canRememberSession && loginShowRememberMe;
|
||||
// Not in the browser the desktop app opened: nothing stays in this browser to remember.
|
||||
const showRememberMe = canRememberSession && loginShowRememberMe && !isDesktopHandoff;
|
||||
const persistSession = canRememberSession && (!loginShowRememberMe || rememberMe);
|
||||
// A provider sign-in follows the box whenever it is shown, the automatic
|
||||
// one included (it starts unticked), and is remembered otherwise. Lite
|
||||
@@ -679,7 +690,8 @@ function LoginPageContent() {
|
||||
locale: params.locale,
|
||||
server_id: selectedServer?.id,
|
||||
remember: persistProviderSession,
|
||||
...(freshLoginRequired() ? { fresh_login: true } : {}),
|
||||
// In the browser the desktop app opened, its word counts: the flag is in the app.
|
||||
...(freshLoginRequired() || (isDesktopHandoff && searchParams.get("fresh_login") === "1") ? { fresh_login: true } : {}),
|
||||
...(isMobileHandoff
|
||||
? {
|
||||
mobile_redirect_uri: mobileRedirectUri,
|
||||
@@ -692,6 +704,8 @@ function LoginPageContent() {
|
||||
|
||||
if (!res.ok) {
|
||||
setOauthLoading(false);
|
||||
// The app is waiting on this tab, so it can't just stay quiet here.
|
||||
if (isDesktopHandoff) setHandoffError("server_error");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -713,7 +727,7 @@ function LoginPageContent() {
|
||||
} catch {
|
||||
setOauthLoading(false);
|
||||
}
|
||||
}, [params.locale, selectedServer?.id, serverUrl, desktopPolicy, tDesktopPolicy, persistProviderSession, isMobileHandoff, mobileRedirectUri, mobileState, mobileChallenge]);
|
||||
}, [params.locale, selectedServer?.id, serverUrl, desktopPolicy, tDesktopPolicy, persistProviderSession, isMobileHandoff, isDesktopHandoff, searchParams, mobileRedirectUri, mobileState, mobileChallenge]);
|
||||
|
||||
useEffect(() => {
|
||||
// After signing out, show the login page rather than signing straight
|
||||
@@ -1041,38 +1055,91 @@ 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 () => {
|
||||
/** What the user sees when a browser sign-in ends without one. Cancelling says nothing. */
|
||||
const showBrowserSignInOutcome = (outcome: DesktopBrowserSignInOutcome) => {
|
||||
setDesktopWaiting(false);
|
||||
if (outcome.kind === "policy") setDesktopPolicyNotice(tDesktopPolicy("server_refused"));
|
||||
else if (outcome.kind === "denied") setDesktopNotice(tBrowser("declined"));
|
||||
else if (outcome.kind === "declined") setDesktopNotice(tDesktop("server_declined"));
|
||||
else if (outcome.kind === "invalid") setDesktopNotice(tDesktop("server_invalid"));
|
||||
else if (outcome.kind === "timeout") setDesktopNotice(tBrowser("timed_out"));
|
||||
else if (outcome.kind === "no_browser") setDesktopNotice(tBrowser("no_browser"));
|
||||
else if (outcome.kind === "failed") setDesktopNotice(tBrowser("failed"));
|
||||
};
|
||||
|
||||
/** The server's or the instance's own sign-in page in the system browser. */
|
||||
const handleBrowserSignIn = async () => {
|
||||
setDesktopNotice(null);
|
||||
setDesktopPolicyNotice(null);
|
||||
// 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.
|
||||
if (process.env.NEXT_PUBLIC_BULWARK_DESKTOP === '1') {
|
||||
const target = jmapEndpoint.trim();
|
||||
if (!target) {
|
||||
setDesktopNotice(tDesktop("server_needed"));
|
||||
return;
|
||||
}
|
||||
setDesktopNotice(null);
|
||||
if (!jmapServerAllowedByDesktopPolicy(target, desktopPolicy)) {
|
||||
setDesktopPolicyNotice(tDesktopPolicy("server_refused"));
|
||||
return;
|
||||
}
|
||||
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,
|
||||
fresh: freshLoginRequired(),
|
||||
redeem: (server, code, verifier, redirectUri) => useAuthStore.getState().loginWithOAuth(server, code, verifier, redirectUri),
|
||||
tryPassword: async ({ username, password, totp }) => {
|
||||
if (await useAuthStore.getState().login(target, username, password, totp, persistSession)) {
|
||||
if (!rememberMeEnabled || persistSession) saveUsername(username);
|
||||
return { ok: true };
|
||||
}
|
||||
const failed = useAuthStore.getState().error;
|
||||
return { ok: false, error: failed === "invalid_credentials" || failed === "totp_required" ? failed : "other" };
|
||||
},
|
||||
});
|
||||
if (outcome.kind === "callback") {
|
||||
window.location.href = outcome.callbackUrl;
|
||||
if (outcome.kind === "signed-in") {
|
||||
setDesktopWaiting(false);
|
||||
router.push('/');
|
||||
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"));
|
||||
showBrowserSignInOutcome(outcome);
|
||||
return;
|
||||
}
|
||||
// A server the device's OS policy refuses, where it's known up front. With a list of
|
||||
// servers the browser picks, and the redeem below is what checks.
|
||||
if (!hasServerList && serverUrl && !jmapServerAllowedByDesktopPolicy(serverUrl, desktopPolicy)) {
|
||||
setDesktopPolicyNotice(tDesktopPolicy("server_refused"));
|
||||
return;
|
||||
}
|
||||
setDesktopWaiting(true);
|
||||
const outcome = await runInstanceBrowserSignIn({
|
||||
base: `${window.location.origin}${getPathPrefix(params.locale as string)}`,
|
||||
locale: params.locale as string,
|
||||
fresh: freshLoginRequired(),
|
||||
});
|
||||
if (outcome.kind !== "code") {
|
||||
showBrowserSignInOutcome(outcome);
|
||||
return;
|
||||
}
|
||||
const redeemed = await useAuthStore.getState().loginWithDesktopHandoff(outcome.code, outcome.verifier, {
|
||||
remember: persistProviderSession,
|
||||
rememberPassword: persistSession,
|
||||
allowedServers: desktopPolicy.AllowedJmapServers.value,
|
||||
// The same rule as the field for a server of one's own on this page.
|
||||
customServersAllowed: customServerAllowedByDesktopPolicy(desktopPolicy) || !configuredServerUrl,
|
||||
});
|
||||
setDesktopWaiting(false);
|
||||
if (redeemed === "ok") router.push('/');
|
||||
else if (redeemed === "blocked") setDesktopPolicyNotice(tDesktopPolicy("server_refused"));
|
||||
else setDesktopNotice(tBrowser("failed"));
|
||||
};
|
||||
|
||||
const browserSignInShown = browserSignInAvailable() && desktopSignInEnabled;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -1080,6 +1147,54 @@ function LoginPageContent() {
|
||||
// when the admin hasn't configured a server list.
|
||||
const effectiveServerUrl = selectedServer?.url
|
||||
|| (allowCustomJmapEndpoint ? jmapEndpoint : serverUrl);
|
||||
// The desktop app opened this page in the user's browser. Nothing leaves this page until
|
||||
// the user says yes: the password is only checked, and a two-factor code can't be, as it
|
||||
// works once. This browser gets no session of its own out of any of it.
|
||||
if (isDesktopHandoff) {
|
||||
const failed = (reason: string) => {
|
||||
useAuthStore.setState({
|
||||
isLoading: false,
|
||||
error: reason === "invalid_credentials" || reason === "totp_required" ? reason : reason === "too_many_attempts" ? null : "server_error",
|
||||
});
|
||||
setDesktopNotice(reason === "too_many_attempts" ? tBrowser("too_many") : null);
|
||||
};
|
||||
const { username, password } = formData;
|
||||
const totp = totpCode || undefined;
|
||||
useAuthStore.setState({ isLoading: true, error: null });
|
||||
setHandoffError(null);
|
||||
setDesktopNotice(null);
|
||||
if (!totp) {
|
||||
const checked = await checkDesktopSignIn({ serverUrl: effectiveServerUrl, serverId: selectedServer?.id, username, password });
|
||||
if ("error" in checked) {
|
||||
failed(checked.error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
useAuthStore.setState({ isLoading: false });
|
||||
const prefix = getPathPrefix(params.locale as string);
|
||||
setPendingHandoff({
|
||||
url: null,
|
||||
resolveUrl: async () => {
|
||||
const parked = await parkDesktopSignIn({
|
||||
challenge: mobileChallenge,
|
||||
state: mobileState,
|
||||
serverUrl: effectiveServerUrl,
|
||||
serverId: selectedServer?.id,
|
||||
username,
|
||||
password,
|
||||
totp,
|
||||
redirectUri: `${window.location.origin}${prefix}/${params.locale}/auth/callback`,
|
||||
});
|
||||
if ("error" in parked) {
|
||||
failed(parked.error);
|
||||
return null;
|
||||
}
|
||||
return buildMobileHandoffCodeUrl(mobileRedirectUri, parked.code, mobileState);
|
||||
},
|
||||
username,
|
||||
});
|
||||
return;
|
||||
}
|
||||
// The device's OS policy, also on an instance: nothing is sent to a server it refuses.
|
||||
setDesktopPolicyNotice(null);
|
||||
if (!jmapServerAllowedByDesktopPolicy(effectiveServerUrl, desktopPolicy)) {
|
||||
@@ -1193,6 +1308,53 @@ function LoginPageContent() {
|
||||
// has to pick which one SSO targets before the button starts the flow, since
|
||||
// the selection drives OAuth discovery and the server_id sent to the callback
|
||||
// (issue #799).
|
||||
// In the app: the main way in, above the form, which stays as the other choice.
|
||||
const browserSignInOption = browserSignInShown && (
|
||||
<div className="space-y-3">
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 font-medium text-[0.9375rem] bg-primary hover:bg-primary/90 transition-all duration-200 rounded-xl shadow-md shadow-primary/15 hover:shadow-lg hover:shadow-primary/20"
|
||||
onClick={handleBrowserSignIn}
|
||||
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 ? tBrowser("waiting") : tBrowser("button")}
|
||||
</Button>
|
||||
{desktopWaiting && (
|
||||
<p role="status" data-testid="desktop-browser-waiting" className="text-center text-xs text-muted-foreground leading-relaxed">
|
||||
{tBrowser("waiting_hint")}{" "}
|
||||
<button
|
||||
type="button"
|
||||
className="underline hover:text-foreground"
|
||||
data-testid="desktop-browser-cancel"
|
||||
onClick={() => {
|
||||
cancelBrowserSignIn();
|
||||
setDesktopWaiting(false);
|
||||
}}
|
||||
>
|
||||
{tBrowser("cancel")}
|
||||
</button>
|
||||
</p>
|
||||
)}
|
||||
<div className="relative py-1">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<span className="w-full border-t border-border/60" />
|
||||
</div>
|
||||
<div className="relative flex justify-center text-xs">
|
||||
<span className="bg-background/80 px-3 text-muted-foreground">{tBrowser("or_here")}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// The desktop app's sign-in, in the browser: says what this page is for.
|
||||
const desktopHandoffBanner = isDesktopHandoff && (
|
||||
<p data-testid="desktop-handoff-banner" className="mb-4 text-center text-sm text-muted-foreground leading-relaxed">
|
||||
{tBrowser("handoff_banner")}
|
||||
</p>
|
||||
);
|
||||
|
||||
const desktopPolicyAlert = desktopPolicyNotice && (
|
||||
<p role="alert" data-testid="desktop-policy-notice" className="mt-2 text-sm text-warning leading-relaxed">
|
||||
{desktopPolicyNotice}
|
||||
@@ -1426,13 +1588,17 @@ function LoginPageContent() {
|
||||
url={pendingHandoff?.url ?? null}
|
||||
resolveUrl={pendingHandoff?.resolveUrl ?? null}
|
||||
username={pendingHandoff?.username ?? ""}
|
||||
desktopPort={isDesktopHandoff ? desktopHandoffPort(desktopTarget) : undefined}
|
||||
onCancel={() => {
|
||||
setPendingHandoff(null);
|
||||
router.push('/');
|
||||
// Ends the flow at the app, which isn't left waiting. Nothing was parked.
|
||||
if (isDesktopHandoff) window.location.replace(desktopHandoffDeclineUrl(desktopTarget, mobileState));
|
||||
else router.push('/');
|
||||
}}
|
||||
onError={() => {
|
||||
setPendingHandoff(null);
|
||||
setHandoffError("server_error");
|
||||
// The desktop's answer has said what went wrong, on the form.
|
||||
if (!isDesktopHandoff) setHandoffError("server_error");
|
||||
}}
|
||||
/>
|
||||
{/* Theme toggle - top right, dropdown style; hidden while the theme policy locks the mode (#1280) */}
|
||||
@@ -1598,6 +1764,8 @@ function LoginPageContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{desktopHandoffBanner}
|
||||
|
||||
{/* Dev Mode: One-click login */}
|
||||
{devMode ? (
|
||||
<div className="space-y-4">
|
||||
@@ -1629,10 +1797,14 @@ function LoginPageContent() {
|
||||
{serverPicker}
|
||||
{desktopPolicyAlert}
|
||||
{oauthMetadata && showRememberMe && rememberMeBox}
|
||||
{browserSignInOption}
|
||||
{oauthMetadata ? (
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full h-11 font-medium text-[0.9375rem] bg-primary hover:bg-primary/90 transition-all duration-200 rounded-xl shadow-md shadow-primary/15 hover:shadow-lg hover:shadow-primary/20"
|
||||
variant={browserSignInShown ? "outline" : undefined}
|
||||
className={browserSignInShown
|
||||
? "w-full h-11 font-medium text-[0.9375rem] rounded-xl border-border/60 hover:bg-muted/50"
|
||||
: "w-full h-11 font-medium text-[0.9375rem] bg-primary hover:bg-primary/90 transition-all duration-200 rounded-xl shadow-md shadow-primary/15 hover:shadow-lg hover:shadow-primary/20"}
|
||||
onClick={() => handleOAuthLogin()}
|
||||
disabled={oauthLoading}
|
||||
>
|
||||
@@ -1663,6 +1835,11 @@ function LoginPageContent() {
|
||||
// Where the sign-in button goes once the provider answers.
|
||||
<Block className="w-full h-11 rounded-xl" />
|
||||
)}
|
||||
{desktopNotice && (
|
||||
<p role="alert" data-testid="desktop-notice" className="mt-2 text-sm text-warning leading-relaxed">
|
||||
{desktopNotice}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
/* Login Form */
|
||||
@@ -1690,6 +1867,8 @@ function LoginPageContent() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{browserSignInOption}
|
||||
|
||||
{/* Username field. The access token names the account, so
|
||||
token sign-in has none. */}
|
||||
<div className={cn("space-y-1.5", signInWithToken && "hidden")}>
|
||||
@@ -1897,7 +2076,10 @@ function LoginPageContent() {
|
||||
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full h-11 font-medium text-[0.9375rem] bg-primary hover:bg-primary/90 transition-all duration-200 rounded-xl shadow-md shadow-primary/15 hover:shadow-lg hover:shadow-primary/20"
|
||||
variant={browserSignInShown ? "outline" : undefined}
|
||||
className={browserSignInShown
|
||||
? "w-full h-11 font-medium text-[0.9375rem] rounded-xl border-border/60 hover:bg-muted/50"
|
||||
: "w-full h-11 font-medium text-[0.9375rem] bg-primary hover:bg-primary/90 transition-all duration-200 rounded-xl shadow-md shadow-primary/15 hover:shadow-lg hover:shadow-primary/20"}
|
||||
disabled={isLoading || (liteSsoRequired && oauthLoading)}
|
||||
>
|
||||
{isLoading || (liteSsoRequired && oauthLoading) ? (
|
||||
@@ -1944,21 +2126,8 @@ 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>
|
||||
)}
|
||||
{desktopPolicyAlert}
|
||||
{IS_DESKTOP_BUILD && desktopNotice && (
|
||||
{desktopNotice && (
|
||||
<p role="alert" data-testid="desktop-notice" className="mt-2 text-sm text-warning leading-relaxed">
|
||||
{desktopNotice}
|
||||
</p>
|
||||
|
||||
@@ -133,6 +133,7 @@ export function SettingsPage() {
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection id="login" title="Login page">
|
||||
<ConfigToggleItem label="Desktop app: sign in with the browser" description="Lets the desktop app sign in through this login page in the user's browser, so their password manager works there. Turn off on a terminal server or any computer several people use at once: another user there can send a link that asks for someone's sign-in. The app's own form keeps working." configKey="desktopBrowserSignIn" value={currentValue('desktopBrowserSignIn') as boolean} source={config.desktopBrowserSignIn?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<ConfigToggleItem label="Show version" description="Show the webmail version in the login page footer. Turn off to keep the exact version hidden from visitors who are not signed in." configKey="loginShowVersion" value={currentValue('loginShowVersion') as boolean} source={config.loginShowVersion?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<ConfigToggleItem label="Show 2FA code option" term="two_factor" description="Show the "I have a 2FA code" toggle on the login form. Turn off when two-factor authentication is handled by an external identity provider. Accounts that require a code still get the field." configKey="loginShowTotp" value={currentValue('loginShowTotp') as boolean} source={config.loginShowTotp?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
<ConfigToggleItem label="Show access token sign-in" term="access_token" description="Offer signing in with an access token instead of a password. For JMAP servers that authenticate API tokens as Bearer tokens, such as Fastmail, which accepts no passwords over JMAP." configKey="loginShowTokenLogin" value={currentValue('loginShowTokenLogin') as boolean} source={config.loginShowTokenLogin?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { resolveDesktopHandoffServer } from '@/lib/auth/desktop-handoff-server';
|
||||
import { loginClientAddress } from '@/lib/auth/client-address';
|
||||
import { isStoreUnavailable, storeUnavailableResponse } from '@/lib/auth/store-unavailable';
|
||||
import { LOGIN_BUDGET_RETRY_AFTER_SECONDS, reserveLoginAttempt } from '@/lib/auth/verify-budget';
|
||||
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
|
||||
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
|
||||
|
||||
// The desktop app's sign-in, in the browser, before the question: is this password right?
|
||||
// So a password manager never saves a wrong one, and the user isn't asked about a sign-in
|
||||
// that can't work. Nothing is kept: the page holds the password until the user says yes.
|
||||
|
||||
const NO_STORE = { 'Cache-Control': 'no-store' };
|
||||
|
||||
function answer(error: string, status: number) {
|
||||
return NextResponse.json({ error }, { status, headers: NO_STORE });
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const crossOrigin = rejectCrossOriginRequest(request);
|
||||
if (crossOrigin) return crossOrigin;
|
||||
try {
|
||||
const body = await request.json().catch(() => null);
|
||||
const { server_url: serverUrl, username, password } = body ?? {};
|
||||
if (
|
||||
typeof serverUrl !== 'string' || !serverUrl || serverUrl.length > 2048
|
||||
|| typeof username !== 'string' || !username || username.length > 512
|
||||
|| typeof password !== 'string' || !password || password.length > 4096
|
||||
) {
|
||||
return answer('invalid_request', 400);
|
||||
}
|
||||
const server = await resolveDesktopHandoffServer(serverUrl, body?.server_id);
|
||||
if ('refusal' in server) return answer(server.refusal.error, server.refusal.status);
|
||||
|
||||
const clientIp = loginClientAddress(request, server.upstream);
|
||||
const refund = await reserveLoginAttempt(clientIp);
|
||||
if (!refund) {
|
||||
return NextResponse.json(
|
||||
{ error: 'too_many_attempts' },
|
||||
{ status: 429, headers: { ...NO_STORE, 'Retry-After': String(LOGIN_BUDGET_RETRY_AFTER_SECONDS) } },
|
||||
);
|
||||
}
|
||||
try {
|
||||
await verifyJmapAuth(server.upstream, `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`, { trusted: server.trusted, clientIp });
|
||||
await refund();
|
||||
return NextResponse.json({ ok: true }, { headers: NO_STORE });
|
||||
} catch (error) {
|
||||
const status = error instanceof JmapAuthVerificationError ? error.upstreamStatus : undefined;
|
||||
if (status === 401) return answer('invalid_credentials', 401);
|
||||
await refund();
|
||||
// Stalwart: the right password on an account with a second factor.
|
||||
if (status === 402) return answer('totp_required', 401);
|
||||
return answer('login_failed', 502);
|
||||
}
|
||||
} catch (error) {
|
||||
if (isStoreUnavailable(error)) return storeUnavailableResponse(NO_STORE);
|
||||
logger.error('Desktop hand-off check error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return answer('Internal server error', 500);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { revokeParkedTokens } from '@/lib/auth/desktop-handoff-revoke';
|
||||
import { discardDesktopHandoff, isHandoffCode } from '@/lib/auth/handoff-store';
|
||||
import { isStoreUnavailable, storeUnavailableResponse } from '@/lib/auth/store-unavailable';
|
||||
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
|
||||
|
||||
// "Not now" on an SSO sign-in the desktop app asked for, or its tab closed. sso/complete
|
||||
// has to park the tokens before the question can be shown, so this is what ends them: gone
|
||||
// from the store, and revoked where the provider can. Only the page holding the code knows it.
|
||||
|
||||
const NO_STORE = { 'Cache-Control': 'no-store' };
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const crossOrigin = rejectCrossOriginRequest(request);
|
||||
if (crossOrigin) return crossOrigin;
|
||||
try {
|
||||
const body = await request.json().catch(() => null);
|
||||
if (!isHandoffCode(body?.code)) return NextResponse.json({ error: 'invalid_code' }, { status: 400, headers: NO_STORE });
|
||||
const parked = await discardDesktopHandoff(body.code);
|
||||
if (parked) {
|
||||
await revokeParkedTokens(parked);
|
||||
logger.info('Desktop hand-off discarded', { flow: parked.flow });
|
||||
}
|
||||
return NextResponse.json({ ok: true }, { headers: NO_STORE });
|
||||
} catch (error) {
|
||||
if (isStoreUnavailable(error)) return storeUnavailableResponse(NO_STORE);
|
||||
logger.error('Desktop hand-off discard error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500, headers: NO_STORE });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { loginClientAddress } from '@/lib/auth/client-address';
|
||||
import { resolveDesktopHandoffServer } from '@/lib/auth/desktop-handoff-server';
|
||||
import { isHandoffCodeChallenge, reserveDesktopHandoffPark, stashHandoff } from '@/lib/auth/handoff-store';
|
||||
import { stalwartPasswordLogin } from '@/lib/auth/stalwart-password-login';
|
||||
import { isStoreUnavailable, storeUnavailableResponse } from '@/lib/auth/store-unavailable';
|
||||
import { LOGIN_BUDGET_RETRY_AFTER_SECONDS, reserveLoginAttempt } from '@/lib/auth/verify-budget';
|
||||
import { JmapAuthVerificationError, verifyJmapAuth } from '@/lib/auth/verify-jmap-auth';
|
||||
import { DEFAULT_CLIENT_ID } from '@/lib/oauth/token-exchange';
|
||||
import { readFileEnv } from '@/lib/read-file-env';
|
||||
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
|
||||
|
||||
// The desktop app's "Sign in with your browser", on the browser's side, after the user
|
||||
// said yes. /check tested the password; until now it has only been in the page. It goes
|
||||
// no further than it has to: Stalwart's login turns it into tokens, and those are what's
|
||||
// parked. A password is parked only for a server with no such login, for 60 seconds, for
|
||||
// whoever holds the verifier. Nothing here writes a cookie, so this browser gets no session.
|
||||
//
|
||||
// A two-factor code works once, so it's only spent here, after the yes. Tokens minted here
|
||||
// are never dropped for budget reasons: the budget is reserved first, and a success gives
|
||||
// it back, so an office behind one address can sign in as often as it likes.
|
||||
|
||||
const NO_STORE = { 'Cache-Control': 'no-store' };
|
||||
|
||||
function bounded(value: unknown, max: number): value is string {
|
||||
return typeof value === 'string' && value.length > 0 && value.length <= max;
|
||||
}
|
||||
|
||||
function answer(error: string, status: number, headers: Record<string, string> = NO_STORE) {
|
||||
return NextResponse.json({ error }, { status, headers });
|
||||
}
|
||||
|
||||
const tooMany = () => answer('too_many_attempts', 429, { ...NO_STORE, 'Retry-After': String(LOGIN_BUDGET_RETRY_AFTER_SECONDS) });
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const crossOrigin = rejectCrossOriginRequest(request);
|
||||
if (crossOrigin) return crossOrigin;
|
||||
try {
|
||||
const body = await request.json().catch(() => null);
|
||||
const challenge = body?.code_challenge;
|
||||
const state = typeof body?.state === 'string' ? body.state : '';
|
||||
const totp = typeof body?.totp === 'string' && body.totp ? body.totp : undefined;
|
||||
if (
|
||||
!isHandoffCodeChallenge(challenge)
|
||||
|| !state
|
||||
|| state.length > 512
|
||||
|| !bounded(body?.server_url, 2048)
|
||||
|| !bounded(body?.username, 512)
|
||||
|| !bounded(body?.password, 4096)
|
||||
|| (totp !== undefined && totp.length > 16)
|
||||
) {
|
||||
return answer('invalid_request', 400);
|
||||
}
|
||||
|
||||
const server = await resolveDesktopHandoffServer(body.server_url, body.server_id);
|
||||
if ('refusal' in server) return answer(server.refusal.error, server.refusal.status);
|
||||
const { entry, resolved, upstream, trusted, custom } = server;
|
||||
|
||||
const clientIp = loginClientAddress(request, upstream);
|
||||
// Before anything goes upstream, so a code is never spent on a sign-in that can't finish.
|
||||
const refundPark = await reserveDesktopHandoffPark(clientIp);
|
||||
if (!refundPark) return tooMany();
|
||||
const refundLogin = await reserveLoginAttempt(clientIp);
|
||||
if (!refundLogin) return tooMany();
|
||||
|
||||
const configuredClientId =
|
||||
entry?.oauth?.clientId || configManager.get<string>('oauthClientId', '') || process.env.OAUTH_CLIENT_ID;
|
||||
const clientSecret =
|
||||
entry?.oauth?.clientSecret
|
||||
|| configManager.get<string>('oauthClientSecret', '')
|
||||
|| process.env.OAUTH_CLIENT_SECRET
|
||||
|| readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE)
|
||||
|| '';
|
||||
const redirectUri = typeof body?.redirect_uri === 'string' && /^https?:\/\//.test(body.redirect_uri) ? body.redirect_uri : upstream;
|
||||
const login = await stalwartPasswordLogin({
|
||||
serverUrl: upstream,
|
||||
username: body.username,
|
||||
password: body.password,
|
||||
totp,
|
||||
clientId: configuredClientId || DEFAULT_CLIENT_ID,
|
||||
clientSecret,
|
||||
redirectUri,
|
||||
trusted,
|
||||
clientIp,
|
||||
registerUnknownClient: !configuredClientId,
|
||||
});
|
||||
|
||||
let parked: Parameters<typeof stashHandoff>[0];
|
||||
if (login.ok) {
|
||||
parked = {
|
||||
flow: 'desktop-tokens',
|
||||
server_url: resolved,
|
||||
server_id: entry?.id ?? null,
|
||||
custom,
|
||||
password_login: true,
|
||||
username: body.username,
|
||||
access_token: login.tokens.access_token,
|
||||
refresh_token: login.tokens.refresh_token,
|
||||
expires_in: login.tokens.expires_in ?? 3600,
|
||||
signed_in_at: Date.now(),
|
||||
};
|
||||
await refundLogin();
|
||||
} else if (login.error === 'invalid_credentials') {
|
||||
return answer('invalid_credentials', 401);
|
||||
} else if (login.error === 'totp_required') {
|
||||
await refundLogin();
|
||||
return answer('totp_required', 401);
|
||||
} else if (totp) {
|
||||
await refundLogin();
|
||||
return answer('login_failed', 502);
|
||||
} else {
|
||||
// The last resort: a server with no token login, or one that won't give this client any.
|
||||
try {
|
||||
await verifyJmapAuth(upstream, `Basic ${Buffer.from(`${body.username}:${body.password}`).toString('base64')}`, { trusted, clientIp });
|
||||
await refundLogin();
|
||||
} catch (error) {
|
||||
const status = error instanceof JmapAuthVerificationError ? error.upstreamStatus : undefined;
|
||||
if (status === 401) return answer('invalid_credentials', 401);
|
||||
await refundLogin();
|
||||
return status === 402 ? answer('totp_required', 401) : answer('login_failed', 502);
|
||||
}
|
||||
parked = { flow: 'desktop-password', server_url: resolved, server_id: entry?.id ?? null, custom, username: body.username, password: body.password };
|
||||
}
|
||||
|
||||
const code = await stashHandoff(parked, { challenge, state });
|
||||
await refundPark();
|
||||
logger.info('Desktop hand-off parked', { flow: parked.flow });
|
||||
return NextResponse.json({ code }, { headers: NO_STORE });
|
||||
} catch (error) {
|
||||
if (isStoreUnavailable(error)) return storeUnavailableResponse(NO_STORE);
|
||||
logger.error('Desktop hand-off park error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return answer('Internal server error', 500);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { MAX_ACCOUNT_SLOTS } from '@/lib/account-utils';
|
||||
import { loginClientAddress } from '@/lib/auth/client-address';
|
||||
import { desktopHandoffEnabled } from '@/lib/auth/desktop-handoff-server';
|
||||
import { revokeParkedTokens } from '@/lib/auth/desktop-handoff-revoke';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { isHandoffCode, isHandoffCodeVerifier, redeemHandoff, reserveHandoffRedeem } from '@/lib/auth/handoff-store';
|
||||
import { writeSignInStamp } from '@/lib/auth/session-age';
|
||||
import { isStoreUnavailable, storeUnavailableResponse } from '@/lib/auth/store-unavailable';
|
||||
import type { BudgetRefund } from '@/lib/auth/verify-budget';
|
||||
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
|
||||
import { jmapServerAllowed } from '@/lib/desktop/policy-schema';
|
||||
import { getCookieOptions, getSignInCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { storeIdToken } from '@/lib/oauth/end-session';
|
||||
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
||||
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
|
||||
|
||||
// The desktop app's side of "Sign in with your browser", on an instance. The app's own
|
||||
// page posts the code its loopback listener received, with the PKCE verifier behind the
|
||||
// challenge it opened the browser with. Same origin as the page, so an instance's cookies
|
||||
// set here work under SameSite=Strict. Every attempt spends the code.
|
||||
//
|
||||
// The device's rules are the page's to send: only it knows the OS policy, and the browser
|
||||
// the user signed in with knows nothing of it. That's its list of servers, and whether it
|
||||
// may use a server of the user's own. A refused server gets nothing written, and a token
|
||||
// minted for it is revoked.
|
||||
|
||||
const NO_STORE = { 'Cache-Control': 'no-store' };
|
||||
|
||||
function answer(error: string, status: number, extra: Record<string, unknown> = {}) {
|
||||
return NextResponse.json({ error, ...extra }, { status, headers: NO_STORE });
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
if (IS_DESKTOP_BUILD) return answer('Not found', 404);
|
||||
const crossOrigin = rejectCrossOriginRequest(request);
|
||||
if (crossOrigin) return crossOrigin;
|
||||
await configManager.ensureLoaded();
|
||||
if (!desktopHandoffEnabled()) return answer('disabled', 403);
|
||||
let refund: BudgetRefund | null = null;
|
||||
try {
|
||||
refund = await reserveHandoffRedeem(loginClientAddress(request));
|
||||
if (!refund) return answer('too_many_requests', 429);
|
||||
|
||||
const body = await request.json().catch(() => null);
|
||||
const code = body?.code;
|
||||
const verifier = body?.code_verifier;
|
||||
if (!isHandoffCode(code)) return answer('invalid_request', 400);
|
||||
if (!isHandoffCodeVerifier(verifier)) {
|
||||
await redeemHandoff(code, '');
|
||||
return answer('invalid_verifier', 400);
|
||||
}
|
||||
const slot = typeof body?.slot === 'number' && Number.isInteger(body.slot) && body.slot >= 0 && body.slot < MAX_ACCOUNT_SLOTS ? body.slot : 0;
|
||||
const remember = body?.remember !== false;
|
||||
const allowedServers = Array.isArray(body?.allowed_servers) ? (body.allowed_servers as string[]) : null;
|
||||
|
||||
const result = await redeemHandoff(code, verifier);
|
||||
if (!result.ok) {
|
||||
logger.warn('Desktop hand-off redeem refused', { reason: result.reason });
|
||||
return answer(result.reason === 'verifier_mismatch' ? 'invalid_verifier' : `${result.reason}_code`, 400);
|
||||
}
|
||||
const parked = result.bundle;
|
||||
// A code the mobile path parked isn't one of ours.
|
||||
if (parked.flow !== 'desktop-password' && parked.flow !== 'desktop-tokens') return answer('invalid_code', 400);
|
||||
if (!jmapServerAllowed(allowedServers, parked.server_url) || (parked.custom && body?.custom_servers_allowed !== true)) {
|
||||
await revokeParkedTokens(parked);
|
||||
return answer('blocked_by_policy', 403, { code: 'blocked_by_policy' });
|
||||
}
|
||||
|
||||
await refund();
|
||||
logger.info('Desktop hand-off redeemed', { flow: parked.flow });
|
||||
|
||||
if (parked.flow === 'desktop-password') {
|
||||
// Down the app's normal login path, from the page, like a typed password.
|
||||
return NextResponse.json(
|
||||
{ flow: 'password', server_url: parked.server_url, username: parked.username, password: parked.password },
|
||||
{ headers: NO_STORE },
|
||||
);
|
||||
}
|
||||
|
||||
const cookieStore = await cookies();
|
||||
if (parked.refresh_token) {
|
||||
const options = getSignInCookieOptions(remember);
|
||||
cookieStore.set(refreshTokenCookieName(slot), parked.refresh_token, options);
|
||||
writeSignInStamp(cookieStore, slot, { signedInAt: parked.signed_in_at, refreshToken: parked.refresh_token }, options);
|
||||
}
|
||||
const serverCookie = refreshTokenServerCookieName(slot);
|
||||
if (parked.server_id) cookieStore.set(serverCookie, parked.server_id, getCookieOptions());
|
||||
else cookieStore.delete(serverCookie);
|
||||
storeIdToken(cookieStore, slot, parked.id_token, request.nextUrl.basePath, [parked.refresh_token, parked.server_id], remember);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
flow: 'tokens',
|
||||
server_url: parked.server_url,
|
||||
...(parked.password_login && parked.username ? { password_login: true, username: parked.username } : {}),
|
||||
access_token: parked.access_token,
|
||||
expires_in: parked.expires_in,
|
||||
signed_in_at: parked.signed_in_at,
|
||||
remember,
|
||||
},
|
||||
{ headers: NO_STORE },
|
||||
);
|
||||
} catch (error) {
|
||||
if (isStoreUnavailable(error)) {
|
||||
if (refund) await refund();
|
||||
return storeUnavailableResponse(NO_STORE);
|
||||
}
|
||||
logger.error('Desktop hand-off redeem error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return answer('Internal server error', 500);
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,8 @@ 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';
|
||||
/** Only tried when the server says no to loopback. Neither Stalwart line takes this one. */
|
||||
const SCHEME_REDIRECT = 'bulwark://sign-in/callback';
|
||||
const REGISTER_TIMEOUT_MS = 5000;
|
||||
|
||||
function isLoopbackRedirect(value: unknown): boolean {
|
||||
@@ -27,29 +29,42 @@ function isLoopbackRedirect(value: unknown): boolean {
|
||||
* 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> {
|
||||
async function postRegistration(endpoint: string, redirectUri: string, scope: string): Promise<Response> {
|
||||
return fetch(endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
client_name: 'Bulwark',
|
||||
redirect_uris: [redirectUri],
|
||||
token_endpoint_auth_method: 'none',
|
||||
grant_types: ['authorization_code', 'refresh_token'],
|
||||
response_types: ['code'],
|
||||
scope,
|
||||
}),
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(REGISTER_TIMEOUT_MS),
|
||||
});
|
||||
}
|
||||
|
||||
async function clientIdOf(response: Response): Promise<string | null> {
|
||||
const body = (await response.json().catch(() => null)) as { client_id?: unknown } | null;
|
||||
return typeof body?.client_id === 'string' && body.client_id ? body.client_id : null;
|
||||
}
|
||||
|
||||
async function registerClient(endpoint: string, scope: string): Promise<{ clientId: string; useScheme: boolean }> {
|
||||
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;
|
||||
const response = await postRegistration(endpoint, REGISTERED_REDIRECT, scope);
|
||||
if (response.ok) return { clientId: (await clientIdOf(response)) ?? FALLBACK_CLIENT_ID, useScheme: false };
|
||||
const refusal = (await response.json().catch(() => null)) as { error?: unknown } | null;
|
||||
if (refusal?.error === 'invalid_redirect_uri') {
|
||||
const second = await postRegistration(endpoint, SCHEME_REDIRECT, scope);
|
||||
const clientId = second.ok ? await clientIdOf(second) : null;
|
||||
if (clientId) return { clientId, useScheme: true };
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Desktop OAuth: client registration failed', { error: error instanceof Error ? error.message : String(error) });
|
||||
return FALLBACK_CLIENT_ID;
|
||||
}
|
||||
return { clientId: FALLBACK_CLIENT_ID, useScheme: false };
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -74,10 +89,17 @@ export async function POST(request: NextRequest) {
|
||||
if (!metadata) return NextResponse.json({ error: 'no_oauth' }, { status: 404 });
|
||||
|
||||
const scope = withProviderDataScopes(DEFAULT_OAUTH_SCOPES, metadata.scopes_supported);
|
||||
// RFC 8414 says where. Without it, Stalwart's path: 0.16's documents don't always name it.
|
||||
const validate = getDiscoveryValidator(upstream.serverUrl);
|
||||
const named = metadata.registration_endpoint;
|
||||
const endpoint = named && (!validate || (await validate(named))) ? named : `${upstream.serverUrl}/auth/register`;
|
||||
const { clientId, useScheme } = await registerClient(endpoint, scope);
|
||||
return NextResponse.json({
|
||||
authorization_endpoint: metadata.authorization_endpoint,
|
||||
client_id: await registerClient(upstream.serverUrl, scope),
|
||||
client_id: clientId,
|
||||
scope,
|
||||
issuer: metadata.issuer,
|
||||
...(useScheme ? { use_scheme: true } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Desktop OAuth discovery failed', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
|
||||
@@ -53,6 +53,11 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error }, { status: 400, headers: NO_STORE });
|
||||
}
|
||||
|
||||
// The desktop app's sign-in has its own redeem, which writes cookies from these fields.
|
||||
if (result.bundle.flow === 'desktop-password' || result.bundle.flow === 'desktop-tokens') {
|
||||
return NextResponse.json({ error: 'invalid_code' }, { status: 400, headers: NO_STORE });
|
||||
}
|
||||
|
||||
await refund();
|
||||
logger.info('Mobile hand-off redeemed', { flow: result.bundle.flow });
|
||||
return NextResponse.json({ ...result.bundle, state: result.state }, { headers: NO_STORE });
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
} from '@/lib/oauth/token-exchange';
|
||||
import { buildRedeemBundle } from '@/lib/auth/pair-bundle';
|
||||
import { isHandoffCodeChallenge, stashHandoff } from '@/lib/auth/handoff-store';
|
||||
import { desktopHandoffTarget } from '@/lib/auth/mobile-handoff';
|
||||
import { isStoreUnavailable, storeUnavailableResponse } from '@/lib/auth/store-unavailable';
|
||||
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
||||
import { getCookieOptions, getSignInCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
@@ -35,6 +36,32 @@ function webmailBaseFromCallback(redirectUri: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Who just signed in, for the question the desktop app's hand-off asks: the id token says, as
|
||||
* it came straight from the provider, and otherwise the mail server's session does.
|
||||
*/
|
||||
async function accountName(serverUrl: string, accessToken: string, idToken: string | undefined): Promise<string> {
|
||||
try {
|
||||
const claims = JSON.parse(Buffer.from((idToken ?? '').split('.')[1] ?? '', 'base64url').toString('utf8')) as Record<string, unknown>;
|
||||
for (const claim of ['email', 'preferred_username', 'name']) {
|
||||
if (typeof claims[claim] === 'string' && claims[claim]) return claims[claim] as string;
|
||||
}
|
||||
} catch {
|
||||
// Not a JWT, or none: the server can say.
|
||||
}
|
||||
try {
|
||||
const session = await fetch(`${serverUrl.replace(/\/+$/, '')}/.well-known/jmap`, {
|
||||
headers: { Authorization: `Bearer ${accessToken}` },
|
||||
redirect: 'error',
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
const body = (await session.json()) as { username?: unknown };
|
||||
return typeof body.username === 'string' ? body.username : '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
// CSRF gate (GHSA-qvr9-m8cq-7wvg): cookies written here are SameSite=Lax,
|
||||
// so a cross-site top-level POST would otherwise reach this handler.
|
||||
@@ -149,6 +176,30 @@ export async function POST(request: NextRequest) {
|
||||
// builds refuse a provider on another host, so those renew through
|
||||
// this webmail - the same bundle a paired phone gets.
|
||||
const { clientId, serverUrl } = getRequiredConfig(pendingServerId);
|
||||
if (mobileRedirectUri && desktopHandoffTarget(mobileRedirectUri)) {
|
||||
// The desktop app on this instance: its redeem writes its own cookies, the way
|
||||
// the code below would have for this browser. sso/start made sure of a challenge.
|
||||
if (!mobileCodeChallenge) throw new Error('desktop hand-off without a challenge');
|
||||
const handoffCode = await stashHandoff({
|
||||
flow: 'desktop-tokens',
|
||||
server_url: serverUrl,
|
||||
server_id: pendingServerId,
|
||||
custom: false,
|
||||
access_token: tokens.access_token,
|
||||
refresh_token: tokens.refresh_token,
|
||||
id_token: tokens.id_token,
|
||||
expires_in: tokens.expires_in ?? 3600,
|
||||
signed_in_at: signedInAt,
|
||||
}, { challenge: mobileCodeChallenge, state: mobileState ?? '', awaitConfirm: true });
|
||||
logger.info('Desktop hand-off parked', { flow: 'desktop-tokens' });
|
||||
return NextResponse.json({
|
||||
mobile_handoff_code: handoffCode,
|
||||
server_url: serverUrl,
|
||||
account: await accountName(serverUrl, tokens.access_token, tokens.id_token),
|
||||
mobile_redirect_uri: mobileRedirectUri,
|
||||
mobile_state: mobileState,
|
||||
});
|
||||
}
|
||||
const bundle = buildRedeemBundle({
|
||||
flow: 'oauth',
|
||||
serverUrl,
|
||||
|
||||
@@ -12,7 +12,8 @@ import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { hasSessionSecret } from '@/lib/auth/session-secret';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { rejectCrossOriginRequest } from '@/lib/security/same-origin';
|
||||
import { isMobileCodeChallenge, mobileHandoffTarget } from '@/lib/auth/mobile-handoff';
|
||||
import { desktopHandoffTarget, isMobileCodeChallenge, mobileHandoffTarget } from '@/lib/auth/mobile-handoff';
|
||||
import { desktopHandoffEnabled } from '@/lib/auth/desktop-handoff-server';
|
||||
|
||||
const SSO_PENDING_COOKIE = 'sso_pending';
|
||||
const SSO_PENDING_MAX_AGE = 300; // 5 minutes
|
||||
@@ -73,9 +74,18 @@ export async function POST(request: NextRequest) {
|
||||
: null;
|
||||
const mobileState =
|
||||
typeof rawMobileState === 'string' && rawMobileState ? rawMobileState : null;
|
||||
if (mobileRedirectUri && !mobileHandoffTarget(mobileRedirectUri)) {
|
||||
if (mobileRedirectUri && !mobileHandoffTarget(mobileRedirectUri) && !desktopHandoffTarget(mobileRedirectUri)) {
|
||||
return NextResponse.json({ error: 'Invalid mobile_redirect_uri' }, { status: 400 });
|
||||
}
|
||||
// The desktop app's listener only ever gets a code. Without a challenge there'd be
|
||||
// tokens in the answer, on the way to a link.
|
||||
if (mobileRedirectUri && desktopHandoffTarget(mobileRedirectUri) && !isMobileCodeChallenge(rawMobileCodeChallenge)) {
|
||||
return NextResponse.json({ error: 'Invalid mobile_code_challenge' }, { status: 400 });
|
||||
}
|
||||
// The admin turned the desktop app's sign-in off here (a terminal server, say).
|
||||
if (mobileRedirectUri && desktopHandoffTarget(mobileRedirectUri) && !desktopHandoffEnabled()) {
|
||||
return NextResponse.json({ error: 'disabled' }, { status: 403 });
|
||||
}
|
||||
// An app build that sends a PKCE challenge gets a one-time code instead
|
||||
// of the tokens (lib/auth/handoff-store.ts). A malformed one is refused
|
||||
// rather than ignored, which would hand the tokens over in the link.
|
||||
|
||||
@@ -89,6 +89,7 @@ export async function GET(request: NextRequest) {
|
||||
loginShowSubtitle: configManager.get<boolean>('loginShowSubtitle', true),
|
||||
loginShowTotp: configManager.get<boolean>('loginShowTotp', true),
|
||||
loginShowTokenLogin: configManager.get<boolean>('loginShowTokenLogin', false),
|
||||
desktopBrowserSignIn: configManager.get<boolean>('desktopBrowserSignIn', true),
|
||||
loginShowRememberMe: configManager.get<boolean>('loginShowRememberMe', false),
|
||||
loginShowVersion: configManager.get<boolean>('loginShowVersion', true),
|
||||
demoMode: configManager.get<boolean>('demoMode', false),
|
||||
|
||||
@@ -19,6 +19,8 @@ interface MobileHandoffConsentProps {
|
||||
onCancel: () => void;
|
||||
/** `resolveUrl` returned null: nothing was handed over. */
|
||||
onError?: () => void;
|
||||
/** The desktop app, not the phone's: the port its listener asked from. */
|
||||
desktopPort?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -29,8 +31,10 @@ interface MobileHandoffConsentProps {
|
||||
* `bulwarkmobile` scheme on the device receives whatever is handed over
|
||||
* (lib/auth/mobile-handoff.ts). Nothing leaves without this confirmation.
|
||||
*/
|
||||
export function MobileHandoffConsent({ url, resolveUrl, username, onCancel, onError }: MobileHandoffConsentProps) {
|
||||
export function MobileHandoffConsent({ url, resolveUrl, username, onCancel, onError, desktopPort }: MobileHandoffConsentProps) {
|
||||
const desktop = Boolean(desktopPort);
|
||||
const t = useTranslations("login");
|
||||
const tDesktop = useTranslations("desktop_browser_sign_in");
|
||||
// The dialog closes itself right after confirming; while the code is being
|
||||
// made live, that close must not count as declining.
|
||||
const resolving = useRef(false);
|
||||
@@ -51,16 +55,18 @@ export function MobileHandoffConsent({ url, resolveUrl, username, onCancel, onEr
|
||||
return;
|
||||
}
|
||||
window.location.replace(resolved);
|
||||
onCancel();
|
||||
// A custom scheme leaves this page where it is. The desktop app's listener is
|
||||
// a page of its own, and going anywhere now could overtake it.
|
||||
if (!desktop) onCancel();
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url) window.location.replace(url);
|
||||
}}
|
||||
title={t("mobile_handoff.title")}
|
||||
message={t("mobile_handoff.description", { username })}
|
||||
confirmText={t("mobile_handoff.confirm")}
|
||||
cancelText={t("mobile_handoff.cancel")}
|
||||
title={desktop ? tDesktop("consent_title") : t("mobile_handoff.title")}
|
||||
message={desktop ? tDesktop("consent_description", { username, port: desktopPort ?? "" }) : t("mobile_handoff.description", { username })}
|
||||
confirmText={desktop ? tDesktop("consent_confirm") : t("mobile_handoff.confirm")}
|
||||
cancelText={desktop ? tDesktop("consent_cancel") : t("mobile_handoff.cancel")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { applyControlsArea, watchTitlebarArea, watchWindowRows, type TitlebarArea } from "@/lib/window-controls-overlay";
|
||||
import { claimBrowserSignIn } from "@/lib/desktop/browser-sign-in";
|
||||
|
||||
/**
|
||||
* The window's controls over the app, with no title bar row: see
|
||||
@@ -10,6 +11,9 @@ import { applyControlsArea, watchTitlebarArea, watchWindowRows, type TitlebarAre
|
||||
* (admin, setup) get it too; there are no messages up there.
|
||||
*/
|
||||
export function WindowControlsHost() {
|
||||
// In the first render of every page, before any frame exists: a frame can reach the
|
||||
// bridge, and whoever comes after this gets nothing.
|
||||
if (typeof window !== "undefined") claimBrowserSignIn();
|
||||
const [area, setArea] = useState<TitlebarArea | null>(null);
|
||||
|
||||
useEffect(() => watchTitlebarArea((next) => {
|
||||
|
||||
@@ -209,6 +209,18 @@
|
||||
<list id="KeystoreAllowedOrigins_Value" valuePrefix=""/>
|
||||
</elements>
|
||||
</policy>
|
||||
<policy name="BrowserSignIn" class="Both" displayName="$(string.BrowserSignIn)" explainText="$(string.BrowserSignIn_Explain)" key="Software\Policies\Bulwark\Desktop" valueName="BrowserSignIn">
|
||||
<parentCategory ref="Bulwark_Desktop_signIn"/>
|
||||
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
|
||||
<enabledValue><decimal value="1"/></enabledValue>
|
||||
<disabledValue><decimal value="0"/></disabledValue>
|
||||
</policy>
|
||||
<policy name="BrowserSignIn_Recommended" class="Both" displayName="$(string.BrowserSignIn)" explainText="$(string.BrowserSignIn_Explain)" key="Software\Policies\Bulwark\Desktop\Recommended" valueName="BrowserSignIn">
|
||||
<parentCategory ref="Bulwark_Desktop_Recommended"/>
|
||||
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
|
||||
<enabledValue><decimal value="1"/></enabledValue>
|
||||
<disabledValue><decimal value="0"/></disabledValue>
|
||||
</policy>
|
||||
<policy name="WipeOfflineData" class="Both" displayName="$(string.WipeOfflineData)" explainText="$(string.WipeOfflineData_Explain)" presentation="$(presentation.WipeOfflineData)" key="Software\Policies\Bulwark\Desktop">
|
||||
<parentCategory ref="Bulwark_Desktop_security"/>
|
||||
<supportedOn ref="SUPPORTED_Bulwark_Desktop"/>
|
||||
|
||||
@@ -56,6 +56,8 @@
|
||||
<string id="AutoSelectCertificateForUrls_Explain">Eine JSON-Regel pro Zeile, im Format von Chrome: {"pattern":"https://[*.]example.com","filter":{"ISSUER":{"CN":"Example CA"}}}. Die App wählt dann für diese Website das passende Zertifikat ohne Rückfrage.</string>
|
||||
<string id="KeystoreAllowedOrigins">Websites, die den Zertifikatspeicher des Systems verwenden dürfen</string>
|
||||
<string id="KeystoreAllowedOrigins_Explain">Die Bulwark-Server, nach https-Adresse, deren Plugins mit den Zertifikaten im Speicher des Betriebssystems signieren und entschlüsseln dürfen, ohne dass der Benutzer für jede Website gefragt wird. Eine pro Zeile.</string>
|
||||
<string id="BrowserSignIn">Anmeldung über den Browser anbieten</string>
|
||||
<string id="BrowserSignIn_Explain">Wenn aktiviert oder nicht konfiguriert, bietet die Anmeldeseite „Mit Ihrem Browser anmelden“ an, damit Benutzer dort ihren Passwort-Manager, Passkeys und Ihr Single Sign-on verwenden können. Wenn deaktiviert, fehlt die Schaltfläche und die App lehnt den Vorgang ab. Das Formular in der App bleibt, sofern der Server nicht nur OAuth erlaubt.</string>
|
||||
<string id="WipeOfflineData">Auf dem Gerät behaltene E-Mails löschen</string>
|
||||
<string id="WipeOfflineData_Explain">Setzen Sie dies auf einen neuen Wert, etwa das heutige Datum, um alles zu löschen, was die App für alle Server und Konten auf dem Gerät behält: E-Mails, Schlüssel und Anmeldungen, Benutzer melden sich also neu an. Jeder Wert löscht einmal: Die App merkt sich, welche sie schon ausgeführt hat, ein Wert kann also stehen bleiben. Verwenden Sie einen neuen Wert, um erneut zu löschen.</string>
|
||||
</stringTable>
|
||||
|
||||
@@ -56,6 +56,8 @@
|
||||
<string id="AutoSelectCertificateForUrls_Explain">One JSON rule per line, in the format Chrome uses: {"pattern":"https://[*.]example.com","filter":{"ISSUER":{"CN":"Example CA"}}}. The app then picks the matching certificate for that site without asking.</string>
|
||||
<string id="KeystoreAllowedOrigins">Sites that may use the system certificate store</string>
|
||||
<string id="KeystoreAllowedOrigins_Explain">The Bulwark servers, by https address, whose plugins may sign and decrypt with the certificates in the operating system's store, without the user being asked for each site. One per line.</string>
|
||||
<string id="BrowserSignIn">Offer sign-in through the browser</string>
|
||||
<string id="BrowserSignIn_Explain">When enabled or not configured, the login page offers "Sign in with your browser", so people can use their password manager, passkeys and your single sign-on there. When disabled, the button is gone and the app refuses the flow. The form in the app stays, unless the server only allows OAuth.</string>
|
||||
<string id="WipeOfflineData">Delete mail kept on the device</string>
|
||||
<string id="WipeOfflineData_Explain">Set this to a new value, such as today's date, to delete everything the app keeps on the device for every server and account: mail, keys, and sign-ins, so people sign in again. Each value deletes once: the app remembers the ones it has acted on, so it stays safe to leave a value in place. Use a new value to delete again.</string>
|
||||
</stringTable>
|
||||
|
||||
@@ -61,6 +61,8 @@
|
||||
<array>
|
||||
<string>https://mail.example.com</string>
|
||||
</array>
|
||||
<key>BrowserSignIn</key>
|
||||
<true/>
|
||||
<key>Recommended</key>
|
||||
<dict>
|
||||
<key>UpdateChannel</key>
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"KeystoreAllowedOrigins": [
|
||||
"https://mail.example.com"
|
||||
],
|
||||
"BrowserSignIn": true,
|
||||
"Recommended": {
|
||||
"UpdateChannel": "stable"
|
||||
}
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface ConfigData {
|
||||
loginShowSubtitle: boolean;
|
||||
loginShowTotp: boolean;
|
||||
loginShowTokenLogin: boolean;
|
||||
desktopBrowserSignIn: boolean;
|
||||
loginShowRememberMe: boolean;
|
||||
loginShowVersion: boolean;
|
||||
demoMode: boolean;
|
||||
@@ -199,6 +200,7 @@ export function useConfig(): AppConfig {
|
||||
loginShowSubtitle: configCache?.loginShowSubtitle ?? true,
|
||||
loginShowTotp: configCache?.loginShowTotp ?? true,
|
||||
loginShowTokenLogin: configCache?.loginShowTokenLogin ?? false,
|
||||
desktopBrowserSignIn: configCache?.desktopBrowserSignIn ?? true,
|
||||
loginShowRememberMe: configCache?.loginShowRememberMe ?? false,
|
||||
loginShowVersion: configCache?.loginShowVersion ?? true,
|
||||
demoMode: configCache?.demoMode || false,
|
||||
@@ -252,6 +254,7 @@ export function useConfig(): AppConfig {
|
||||
loginShowSubtitle: configCache.loginShowSubtitle,
|
||||
loginShowTotp: configCache.loginShowTotp,
|
||||
loginShowTokenLogin: configCache.loginShowTokenLogin,
|
||||
desktopBrowserSignIn: configCache.desktopBrowserSignIn,
|
||||
loginShowRememberMe: configCache.loginShowRememberMe,
|
||||
loginShowVersion: configCache.loginShowVersion,
|
||||
demoMode: configCache.demoMode,
|
||||
@@ -306,6 +309,7 @@ export function useConfig(): AppConfig {
|
||||
loginShowSubtitle: data.loginShowSubtitle,
|
||||
loginShowTotp: data.loginShowTotp,
|
||||
loginShowTokenLogin: data.loginShowTokenLogin,
|
||||
desktopBrowserSignIn: data.desktopBrowserSignIn,
|
||||
loginShowRememberMe: data.loginShowRememberMe,
|
||||
loginShowVersion: data.loginShowVersion,
|
||||
demoMode: data.demoMode,
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { createHash } from 'crypto';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
/**
|
||||
* The desktop app's sign-in on an instance: what the browser checks and parks, and what the
|
||||
* app's own page gets for the code. The real store, budgets and routes, with the mail server faked.
|
||||
*/
|
||||
|
||||
const cookieJar = vi.hoisted(() => new Map<string, { value: string; options?: Record<string, unknown> }>());
|
||||
vi.mock('next/headers', () => ({
|
||||
cookies: async () => ({
|
||||
get: (name: string) => (cookieJar.has(name) ? { name, value: cookieJar.get(name)!.value } : undefined),
|
||||
set: (name: string, value: string, options?: Record<string, unknown>) => {
|
||||
cookieJar.set(name, { value, options });
|
||||
},
|
||||
delete: (name: string) => {
|
||||
cookieJar.delete(name);
|
||||
},
|
||||
getAll: () => [...cookieJar.entries()].map(([name, { value }]) => ({ name, value })),
|
||||
}),
|
||||
}));
|
||||
|
||||
const config = vi.hoisted(() => ({ values: {} as Record<string, unknown> }));
|
||||
vi.mock('@/lib/admin/config-manager', () => ({
|
||||
configManager: {
|
||||
ensureLoaded: async () => {},
|
||||
get: (key: string, fallback: unknown) => (key in config.values ? config.values[key] : fallback),
|
||||
},
|
||||
}));
|
||||
|
||||
const verifyJmapAuth = vi.hoisted(() => vi.fn());
|
||||
vi.mock('@/lib/auth/verify-jmap-auth', async (original) => ({
|
||||
...(await original<object>()),
|
||||
verifyJmapAuth,
|
||||
}));
|
||||
|
||||
const stalwartPasswordLogin = vi.hoisted(() => vi.fn());
|
||||
vi.mock('@/lib/auth/stalwart-password-login', async (original) => ({
|
||||
...(await original<object>()),
|
||||
stalwartPasswordLogin,
|
||||
}));
|
||||
|
||||
const reserveLoginAttempt = vi.hoisted(() => vi.fn());
|
||||
vi.mock('@/lib/auth/verify-budget', async (original) => ({
|
||||
...(await original<object>()),
|
||||
reserveLoginAttempt,
|
||||
}));
|
||||
|
||||
const discoverOAuth = vi.hoisted(() => vi.fn());
|
||||
vi.mock('@/lib/oauth/discovery', () => ({ discoverOAuth }));
|
||||
|
||||
vi.mock('@/lib/logger', () => ({ logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() } }));
|
||||
|
||||
const { POST: check } = await import('@/app/api/auth/desktop-handoff/check/route');
|
||||
const { POST: park } = await import('@/app/api/auth/desktop-handoff/park/route');
|
||||
const { POST: redeem } = await import('@/app/api/auth/desktop-handoff/redeem/route');
|
||||
const { POST: discard } = await import('@/app/api/auth/desktop-handoff/discard/route');
|
||||
const { POST: mobileRedeem } = await import('@/app/api/auth/handoff/redeem/route');
|
||||
const { JmapAuthVerificationError } = await import('@/lib/auth/verify-jmap-auth');
|
||||
const { HANDOFF_CONFIRM_TTL_MS, HANDOFF_TTL_MS, resetHandoffStoreForTests, stashHandoff } = await import('@/lib/auth/handoff-store');
|
||||
const { desktopHandoffDeclineUrl, desktopHandoffPort, desktopHandoffTarget, mobileHandoffTarget } = await import('@/lib/auth/mobile-handoff');
|
||||
const { isDesktopBlockedRoute } = await import('@/lib/desktop/blocked-routes');
|
||||
|
||||
const ORIGIN = 'https://bulwark.example';
|
||||
const VERIFIER = 'v'.repeat(64);
|
||||
const CHALLENGE = createHash('sha256').update(VERIFIER, 'ascii').digest('base64url');
|
||||
const SERVER = 'https://mail.example';
|
||||
const TOKENS = { ok: true, tokens: { access_token: 'access', refresh_token: 'refresh', expires_in: 3600 }, tokenEndpoint: `${SERVER}/auth/token`, clientId: 'x' };
|
||||
|
||||
/** A client of its own per test, so the budgets don't carry over: the real ones, unmocked. */
|
||||
let clientAddress = '';
|
||||
let nextClient = 1;
|
||||
|
||||
function post(path: string, body: unknown, origin = ORIGIN) {
|
||||
return new NextRequest(`${ORIGIN}${path}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Origin: origin, Host: 'bulwark.example', 'x-forwarded-for': clientAddress },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
const parkBody = (extra: Record<string, unknown> = {}) => ({
|
||||
code_challenge: CHALLENGE,
|
||||
state: 'state-1',
|
||||
server_url: SERVER,
|
||||
username: 'a@example.org',
|
||||
password: 'secret',
|
||||
...extra,
|
||||
});
|
||||
const checkBody = (extra: Record<string, unknown> = {}) => ({ server_url: SERVER, username: 'a@example.org', password: 'secret', ...extra });
|
||||
|
||||
const parkIt = (extra: Record<string, unknown> = {}) => park(post('/api/auth/desktop-handoff/park', parkBody(extra)));
|
||||
|
||||
async function parkedCode(extra: Record<string, unknown> = {}): Promise<string> {
|
||||
const answer = await parkIt(extra);
|
||||
expect(answer.status).toBe(200);
|
||||
return ((await answer.json()) as { code: string }).code;
|
||||
}
|
||||
|
||||
const redeemBody = (code: string, extra: Record<string, unknown> = {}) => ({ code, code_verifier: VERIFIER, slot: 1, remember: true, allowed_servers: null, custom_servers_allowed: true, ...extra });
|
||||
const redeemIt = (code: string, extra: Record<string, unknown> = {}) => redeem(post('/api/auth/desktop-handoff/redeem', redeemBody(code, extra)));
|
||||
|
||||
/** Everything the store holds, in the open. Nothing secret may be in it after its use. */
|
||||
const heldInMemory = () => JSON.stringify([...((globalThis as unknown as { __bulwarkMobileHandoffStore?: Map<string, unknown> }).__bulwarkMobileHandoffStore ?? new Map()).values()]);
|
||||
|
||||
let revoked: string[];
|
||||
|
||||
beforeEach(() => {
|
||||
cookieJar.clear();
|
||||
resetHandoffStoreForTests();
|
||||
clientAddress = `203.0.113.${(nextClient += 1)}`;
|
||||
config.values = { jmapServerUrl: SERVER };
|
||||
verifyJmapAuth.mockReset();
|
||||
verifyJmapAuth.mockResolvedValue(undefined);
|
||||
stalwartPasswordLogin.mockReset();
|
||||
stalwartPasswordLogin.mockResolvedValue(TOKENS);
|
||||
reserveLoginAttempt.mockReset();
|
||||
reserveLoginAttempt.mockResolvedValue(async () => {});
|
||||
discoverOAuth.mockReset();
|
||||
discoverOAuth.mockResolvedValue({ revocation_endpoint: `${SERVER}/revoke` });
|
||||
revoked = [];
|
||||
vi.stubGlobal('fetch', vi.fn(async (url: string, init?: RequestInit) => {
|
||||
if (String(url).endsWith('/revoke')) revoked.push(new URLSearchParams(String(init?.body)).get('token') ?? '');
|
||||
return new Response('{}', { status: 200 });
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe('the target', () => {
|
||||
it('is only ever this computer on the one path', () => {
|
||||
expect(desktopHandoffTarget('http://127.0.0.1:51234/bulwark-desktop/callback')).toBe('http://127.0.0.1:51234/bulwark-desktop/callback');
|
||||
for (const bad of [
|
||||
'http://127.0.0.1/bulwark-desktop/callback',
|
||||
'http://127.0.0.1:99999/bulwark-desktop/callback',
|
||||
'http://localhost:51234/bulwark-desktop/callback',
|
||||
'https://127.0.0.1:51234/bulwark-desktop/callback',
|
||||
'http://127.0.0.1:51234/other',
|
||||
'http://127.0.0.1:51234/bulwark-desktop/callback?x=1',
|
||||
'http://127.0.0.1@evil.example:51234/bulwark-desktop/callback',
|
||||
'http://evil.example/bulwark-desktop/callback',
|
||||
'bulwarkmobile://auth/callback',
|
||||
null,
|
||||
]) {
|
||||
expect(desktopHandoffTarget(bad), String(bad)).toBe('');
|
||||
}
|
||||
});
|
||||
|
||||
it('leaves the phone\'s rule as it was', () => {
|
||||
expect(mobileHandoffTarget('http://127.0.0.1:51234/bulwark-desktop/callback')).toBe('');
|
||||
});
|
||||
|
||||
it('a no goes back with the state and no code', () => {
|
||||
const target = 'http://127.0.0.1:51234/bulwark-desktop/callback';
|
||||
expect(desktopHandoffPort(target)).toBe('51234');
|
||||
expect(desktopHandoffDeclineUrl(target, 's t')).toBe(`${target}?error=access_denied&state=s+t`);
|
||||
});
|
||||
|
||||
it('the embedded build has none of these routes', () => {
|
||||
for (const route of ['check', 'park', 'redeem', 'discard']) expect(isDesktopBlockedRoute(`/api/auth/desktop-handoff/${route}`)).toBe(true);
|
||||
expect(isDesktopBlockedRoute('/api/auth/desktop-oauth')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('checking, before the question', () => {
|
||||
const checkIt = (extra: Record<string, unknown> = {}, origin = ORIGIN) => check(post('/api/auth/desktop-handoff/check', checkBody(extra), origin));
|
||||
|
||||
it('keeps nothing: no record, no cookie, and the password appears nowhere', async () => {
|
||||
const answer = await checkIt();
|
||||
expect(answer.status).toBe(200);
|
||||
expect(heldInMemory()).toBe('[]');
|
||||
expect([...cookieJar.keys()]).toEqual([]);
|
||||
expect(stalwartPasswordLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses another origin', async () => {
|
||||
expect((await checkIt({}, 'https://evil.example')).status).toBe(403);
|
||||
expect(verifyJmapAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('says a wrong password is wrong, and that a second factor is needed', async () => {
|
||||
verifyJmapAuth.mockRejectedValueOnce(new JmapAuthVerificationError('no', 401, 401));
|
||||
expect(await (await checkIt()).json()).toEqual({ error: 'invalid_credentials' });
|
||||
verifyJmapAuth.mockRejectedValueOnce(new JmapAuthVerificationError('no', 401, 402));
|
||||
expect(await (await checkIt()).json()).toEqual({ error: 'totp_required' });
|
||||
});
|
||||
|
||||
it('holds no password on an OAuth-only instance', async () => {
|
||||
config.values = { jmapServerUrl: SERVER, oauthEnabled: true, oauthOnly: true };
|
||||
expect((await checkIt()).status).toBe(403);
|
||||
expect(verifyJmapAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('over the login budget, nothing reaches the mail server, and the answer says why', async () => {
|
||||
reserveLoginAttempt.mockResolvedValue(null);
|
||||
const answer = await checkIt();
|
||||
expect(answer.status).toBe(429);
|
||||
expect(await answer.json()).toEqual({ error: 'too_many_attempts' });
|
||||
expect(verifyJmapAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never goes to a server the instance does not know', async () => {
|
||||
expect((await checkIt({ server_url: 'https://evil.example' })).status).toBe(400);
|
||||
expect(verifyJmapAuth).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('the instance admin\'s switch', () => {
|
||||
it('closes every route, with nothing checked, parked or redeemed', async () => {
|
||||
const code = await parkedCode();
|
||||
verifyJmapAuth.mockClear();
|
||||
stalwartPasswordLogin.mockClear();
|
||||
config.values = { jmapServerUrl: SERVER, desktopBrowserSignIn: false };
|
||||
expect((await check(post('/api/auth/desktop-handoff/check', checkBody()))).status).toBe(403);
|
||||
expect((await parkIt()).status).toBe(403);
|
||||
expect(verifyJmapAuth).not.toHaveBeenCalled();
|
||||
expect(stalwartPasswordLogin).not.toHaveBeenCalled();
|
||||
expect((await redeemIt(code)).status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parking, after the yes', () => {
|
||||
it('hands over tokens wherever the server can give them, and leaves this browser with no session', async () => {
|
||||
const code = await parkedCode();
|
||||
expect(verifyJmapAuth).not.toHaveBeenCalled();
|
||||
expect([...cookieJar.keys()]).toEqual([]);
|
||||
expect(heldInMemory()).not.toContain('secret');
|
||||
const answer = await redeemIt(code);
|
||||
const body = await answer.json();
|
||||
expect(body).toMatchObject({ flow: 'tokens', password_login: true, username: 'a@example.org', access_token: 'access' });
|
||||
expect(JSON.stringify(body)).not.toContain('secret');
|
||||
expect(JSON.stringify(body)).not.toContain('"refresh"');
|
||||
expect(cookieJar.get('jmap_rt_1')?.value).toBe('refresh');
|
||||
});
|
||||
|
||||
it.each(['login_endpoint_missing', 'login_rejected', 'login_unreachable', 'token_exchange_failed'])('a password only as the last resort: %s', async (error) => {
|
||||
stalwartPasswordLogin.mockResolvedValue({ ok: false, error });
|
||||
const code = await parkedCode();
|
||||
expect(verifyJmapAuth).toHaveBeenCalledOnce();
|
||||
expect(await (await redeemIt(code)).json()).toEqual({ flow: 'password', server_url: SERVER, username: 'a@example.org', password: 'secret' });
|
||||
});
|
||||
|
||||
it('a password that turns out wrong, on the way, parks nothing', async () => {
|
||||
stalwartPasswordLogin.mockResolvedValue({ ok: false, error: 'invalid_credentials' });
|
||||
const answer = await parkIt();
|
||||
expect(answer.status).toBe(401);
|
||||
expect(await answer.json()).toEqual({ error: 'invalid_credentials' });
|
||||
expect(heldInMemory()).toBe('[]');
|
||||
});
|
||||
|
||||
it('a wrong two-factor code gets no fallback to a password', async () => {
|
||||
stalwartPasswordLogin.mockResolvedValue({ ok: false, error: 'login_rejected' });
|
||||
expect((await parkIt({ totp: '000000' })).status).toBe(502);
|
||||
expect(verifyJmapAuth).not.toHaveBeenCalled();
|
||||
expect(heldInMemory()).toBe('[]');
|
||||
});
|
||||
|
||||
it('refuses another origin', async () => {
|
||||
expect((await park(post('/api/auth/desktop-handoff/park', parkBody(), 'https://evil.example'))).status).toBe(403);
|
||||
expect(stalwartPasswordLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['no challenge', { code_challenge: '' }],
|
||||
['a bad challenge', { code_challenge: 'short' }],
|
||||
['no state', { state: '' }],
|
||||
['no password', { password: '' }],
|
||||
])('refuses %s', async (_name, extra) => {
|
||||
expect((await parkIt(extra)).status).toBe(400);
|
||||
expect(stalwartPasswordLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('what was checked is what is parked: the body can\'t name another server for the app', async () => {
|
||||
config.values = { jmapServerUrl: SERVER, jmapServers: [{ id: 'two', url: 'https://two.example', name: 'Two' }] };
|
||||
const code = await parkedCode({ server_id: 'two', server_url: 'https://evil.example' });
|
||||
expect(stalwartPasswordLogin.mock.calls[0][0]).toMatchObject({ serverUrl: 'https://two.example' });
|
||||
expect(await (await redeemIt(code)).json()).toMatchObject({ server_url: 'https://two.example' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the budget, with one address for a whole office', () => {
|
||||
it('sign-ins that work never run out', async () => {
|
||||
clientAddress = '198.51.100.7';
|
||||
for (let i = 0; i < 25; i += 1) expect((await parkIt()).status, `sign-in ${i}`).toBe(200);
|
||||
});
|
||||
|
||||
it('after ten failures it refuses before going upstream, so no code is spent, and says why', async () => {
|
||||
clientAddress = '198.51.100.8';
|
||||
stalwartPasswordLogin.mockResolvedValue({ ok: false, error: 'invalid_credentials' });
|
||||
for (let i = 0; i < 10; i += 1) expect((await parkIt({ totp: '000000' })).status).toBe(401);
|
||||
stalwartPasswordLogin.mockClear();
|
||||
const answer = await parkIt({ totp: '123456' });
|
||||
expect(answer.status).toBe(429);
|
||||
expect(await answer.json()).toEqual({ error: 'too_many_attempts' });
|
||||
expect(answer.headers.get('Retry-After')).toBeTruthy();
|
||||
expect(stalwartPasswordLogin).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a bad code to redeem is what its budget counts, and a good one gives it back', async () => {
|
||||
clientAddress = '198.51.100.9';
|
||||
for (let i = 0; i < 12; i += 1) expect((await redeemIt(await parkedCode())).status).toBe(200);
|
||||
for (let i = 0; i < 10; i += 1) expect((await redeemIt('f'.repeat(64))).status).toBe(400);
|
||||
expect((await redeemIt('f'.repeat(64))).status).toBe(429);
|
||||
});
|
||||
});
|
||||
|
||||
describe('redeeming, in the app', () => {
|
||||
it('a code can be used once', async () => {
|
||||
const code = await parkedCode();
|
||||
expect((await redeemIt(code)).status).toBe(200);
|
||||
const again = await redeemIt(code);
|
||||
expect(again.status).toBe(400);
|
||||
expect(await again.json()).toEqual({ error: 'invalid_code' });
|
||||
});
|
||||
|
||||
it('a wrong verifier gets nothing, and spends the code', async () => {
|
||||
const code = await parkedCode();
|
||||
const wrong = await redeemIt(code, { code_verifier: 'w'.repeat(64) });
|
||||
expect(wrong.status).toBe(400);
|
||||
expect(await wrong.json()).toEqual({ error: 'invalid_verifier' });
|
||||
expect((await redeemIt(code)).status).toBe(400);
|
||||
expect([...cookieJar.keys()]).toEqual([]);
|
||||
});
|
||||
|
||||
it('a late code is told so', async () => {
|
||||
const code = await parkedCode();
|
||||
const now = Date.now();
|
||||
vi.spyOn(Date, 'now').mockReturnValue(now + 61_000);
|
||||
const answer = await redeemIt(code);
|
||||
expect(answer.status).toBe(400);
|
||||
expect(await answer.json()).toEqual({ error: 'expired_code' });
|
||||
});
|
||||
|
||||
it('keeps the instance\'s strict cookies strict', async () => {
|
||||
config.values = { jmapServerUrl: SERVER, cookieSameSite: 'strict' };
|
||||
await redeemIt(await parkedCode());
|
||||
expect(cookieJar.get('jmap_rt_1')?.options).toMatchObject({ sameSite: 'strict', httpOnly: true });
|
||||
});
|
||||
|
||||
it('a server off the device\'s list gets nothing written, the token is revoked, and the code is gone', async () => {
|
||||
const code = await parkedCode();
|
||||
const answer = await redeemIt(code, { allowed_servers: ['https://other.example'] });
|
||||
expect(answer.status).toBe(403);
|
||||
expect(await answer.json()).toMatchObject({ code: 'blocked_by_policy' });
|
||||
expect([...cookieJar.keys()]).toEqual([]);
|
||||
expect(revoked).toEqual(['refresh']);
|
||||
expect((await redeemIt(code)).status).toBe(400);
|
||||
});
|
||||
|
||||
it('a list that names the server lets it through', async () => {
|
||||
expect((await redeemIt(await parkedCode(), { allowed_servers: [SERVER] })).status).toBe(200);
|
||||
});
|
||||
|
||||
describe('a server of the user\'s own, on an instance that allows them', () => {
|
||||
const OWN = 'https://1.1.1.1';
|
||||
beforeEach(() => {
|
||||
config.values = { jmapServerUrl: SERVER, allowCustomJmapEndpoint: true };
|
||||
});
|
||||
|
||||
it('is refused when the device\'s rule says no: forced InstanceUrl, or AllowStandalone off', async () => {
|
||||
const code = await parkedCode({ server_url: OWN });
|
||||
const answer = await redeemIt(code, { custom_servers_allowed: false });
|
||||
expect(answer.status).toBe(403);
|
||||
expect(await answer.json()).toMatchObject({ code: 'blocked_by_policy' });
|
||||
expect([...cookieJar.keys()]).toEqual([]);
|
||||
});
|
||||
|
||||
it('is refused when a page says nothing about it', async () => {
|
||||
const code = await parkedCode({ server_url: OWN });
|
||||
const { custom_servers_allowed: _left, ...withoutRule } = redeemBody(code);
|
||||
expect((await redeem(post('/api/auth/desktop-handoff/redeem', withoutRule))).status).toBe(403);
|
||||
});
|
||||
|
||||
it('goes through when the device allows it', async () => {
|
||||
expect((await redeemIt(await parkedCode({ server_url: OWN }))).status).toBe(200);
|
||||
});
|
||||
|
||||
it('the instance\'s own server is no such thing', async () => {
|
||||
expect((await redeemIt(await parkedCode(), { custom_servers_allowed: false })).status).toBe(200);
|
||||
});
|
||||
});
|
||||
|
||||
it('refuses another origin', async () => {
|
||||
const code = await parkedCode();
|
||||
expect((await redeem(post('/api/auth/desktop-handoff/redeem', redeemBody(code), 'https://evil.example'))).status).toBe(403);
|
||||
});
|
||||
});
|
||||
|
||||
describe('what stays behind', () => {
|
||||
const ssoBundle = {
|
||||
flow: 'desktop-tokens' as const,
|
||||
server_url: SERVER,
|
||||
server_id: null,
|
||||
custom: false,
|
||||
access_token: 'sso-access',
|
||||
refresh_token: 'sso-refresh',
|
||||
expires_in: 3600,
|
||||
signed_in_at: 1,
|
||||
};
|
||||
const parkedSso = () => stashHandoff(ssoBundle, { challenge: CHALLENGE, state: 's', awaitConfirm: true });
|
||||
const discardIt = (code: string) => discard(post('/api/auth/desktop-handoff/discard', { code }));
|
||||
|
||||
it('"Not now" takes the tokens out of the store and revokes the refresh token', async () => {
|
||||
const code = await parkedSso();
|
||||
expect(heldInMemory()).toContain('sso-refresh');
|
||||
expect((await discardIt(code)).status).toBe(200);
|
||||
expect(heldInMemory()).not.toContain('sso-refresh');
|
||||
expect(heldInMemory()).not.toContain('sso-access');
|
||||
expect(revoked).toEqual(['sso-refresh']);
|
||||
expect((await redeemIt(code)).status).toBe(400);
|
||||
});
|
||||
|
||||
it('a provider with no revocation endpoint: the store is still emptied', async () => {
|
||||
discoverOAuth.mockResolvedValue({});
|
||||
const code = await parkedSso();
|
||||
expect((await discardIt(code)).status).toBe(200);
|
||||
expect(heldInMemory()).not.toContain('sso-refresh');
|
||||
expect(revoked).toEqual([]);
|
||||
});
|
||||
|
||||
it('discards only the desktop\'s, and nothing twice', async () => {
|
||||
const phone = await stashHandoff({ flow: 'password', server_url: SERVER, username: 'a', password: 'phone-secret' }, { challenge: CHALLENGE, state: 's' });
|
||||
await discardIt(phone);
|
||||
expect(heldInMemory()).toContain('phone-secret');
|
||||
const code = await parkedSso();
|
||||
await discardIt(code);
|
||||
await discardIt(code);
|
||||
expect(revoked).toEqual(['sso-refresh']);
|
||||
});
|
||||
|
||||
it('a code no one knows gets nothing', async () => {
|
||||
expect((await discardIt('nonsense')).status).toBe(400);
|
||||
});
|
||||
|
||||
it('with nobody coming back, a timer wipes what was parked, at its real expiry', async () => {
|
||||
vi.useFakeTimers();
|
||||
await stashHandoff({ flow: 'desktop-password', server_url: SERVER, server_id: null, custom: false, username: 'a', password: 'timer-secret' }, { challenge: CHALLENGE, state: 's' });
|
||||
await parkedSso();
|
||||
expect(heldInMemory()).toContain('timer-secret');
|
||||
vi.advanceTimersByTime(HANDOFF_TTL_MS + 2);
|
||||
expect(heldInMemory()).not.toContain('timer-secret');
|
||||
expect(heldInMemory()).toContain('sso-refresh');
|
||||
vi.advanceTimersByTime(HANDOFF_CONFIRM_TTL_MS);
|
||||
expect(heldInMemory()).not.toContain('sso-refresh');
|
||||
});
|
||||
});
|
||||
|
||||
describe('the two kinds of code stay apart', () => {
|
||||
it('the phone\'s redeem gives away nothing of a desktop sign-in', async () => {
|
||||
const code = await parkedCode();
|
||||
const answer = await mobileRedeem(post('/api/auth/handoff/redeem', { code, code_verifier: VERIFIER }));
|
||||
expect(answer.status).toBe(400);
|
||||
const body = JSON.stringify(await answer.json());
|
||||
expect(body).not.toContain('refresh');
|
||||
expect(body).not.toContain('access');
|
||||
});
|
||||
|
||||
it('the desktop redeem takes no phone code', async () => {
|
||||
const code = await stashHandoff({ flow: 'password', server_url: SERVER, username: 'a', password: 'secret' }, { challenge: CHALLENGE, state: 's' });
|
||||
const answer = await redeemIt(code);
|
||||
expect(answer.status).toBe(400);
|
||||
expect(JSON.stringify(await answer.json())).not.toContain('secret');
|
||||
});
|
||||
});
|
||||
@@ -32,6 +32,7 @@ export function createConfig(overrides: Partial<ConfigData> = {}): ConfigData {
|
||||
loginShowSubtitle: true,
|
||||
loginShowTotp: true,
|
||||
loginShowTokenLogin: false,
|
||||
desktopBrowserSignIn: true,
|
||||
loginShowRememberMe: false,
|
||||
loginShowVersion: true,
|
||||
demoMode: false,
|
||||
|
||||
@@ -47,6 +47,14 @@ describe('oauth/discovery', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('keeps a registration endpoint the server names, for the desktop app, and only a string', async () => {
|
||||
const answer = (extra: Record<string, unknown>) => ({ status: 200, ok: true, headers: new Headers(), json: async () => ({ ...VALID_METADATA, ...extra }) });
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(answer({ registration_endpoint: 'https://auth.example.com/register' })));
|
||||
expect((await discoverOAuth('https://a.example.com', { validateEndpoint }))?.registration_endpoint).toBe('https://auth.example.com/register');
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(answer({ registration_endpoint: 7 })));
|
||||
expect((await discoverOAuth('https://b.example.com', { validateEndpoint }))?.registration_endpoint).toBeUndefined();
|
||||
});
|
||||
|
||||
it('follows one redirect that stays on the same host', async () => {
|
||||
const redirect = { status: 301, ok: false, headers: new Headers({ location: '/auth/.well-known/openid-configuration' }), json: async () => ({}) };
|
||||
const document = { status: 200, ok: true, headers: new Headers(), json: async () => VALID_METADATA };
|
||||
|
||||
@@ -109,3 +109,43 @@ describe('mobile hand-off with a shared store', () => {
|
||||
expect(await (await replica()).redeemHandoff(code, VERIFIER, T0 + 2)).toMatchObject({ ok: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe('what a shared store holds after a sign-in stops being any use', () => {
|
||||
const key = (code: string) => 'handoff:' + createHash('sha256').update(code).digest('hex');
|
||||
const DESKTOP = { flow: 'desktop-tokens' as const, server_url: 'https://mail.example.org', server_id: null, custom: false, access_token: 'a', refresh_token: 'r', expires_in: 60, signed_in_at: 1 };
|
||||
|
||||
it('a record with a sign-in in it is gone from the store at its real expiry, and a late code is still told so', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
vi.setSystemTime(T0);
|
||||
const { stashHandoff, redeemHandoff, HANDOFF_TTL_MS } = await replica();
|
||||
const code = await stashHandoff(PASSWORD_BUNDLE, { challenge: CHALLENGE, state: 's' }, T0);
|
||||
expect(await getStore().kv.get(key(code))).not.toBeNull();
|
||||
vi.setSystemTime(T0 + HANDOFF_TTL_MS + 1);
|
||||
expect(await getStore().kv.get(key(code))).toBeNull();
|
||||
expect(await redeemHandoff(code, VERIFIER, T0 + HANDOFF_TTL_MS + 1)).toEqual({ ok: false, reason: 'expired' });
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('a confirmed code stays no longer than its 60 seconds', async () => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] });
|
||||
vi.setSystemTime(T0);
|
||||
const { stashHandoff, confirmHandoff, HANDOFF_TTL_MS } = await replica();
|
||||
const code = await stashHandoff(DESKTOP, { challenge: CHALLENGE, state: 's', awaitConfirm: true }, T0);
|
||||
expect(await confirmHandoff(code, T0)).toBe(true);
|
||||
vi.setSystemTime(T0 + HANDOFF_TTL_MS + 1);
|
||||
expect(await getStore().kv.get(key(code))).toBeNull();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('a discard takes the sign-in out at once, gives it back once, and only the desktop\'s', async () => {
|
||||
const { stashHandoff, discardDesktopHandoff, redeemHandoff } = await replica();
|
||||
const code = await stashHandoff(DESKTOP, { challenge: CHALLENGE, state: 's', awaitConfirm: true });
|
||||
const other = await replica();
|
||||
expect(await other.discardDesktopHandoff(code)).toMatchObject({ refresh_token: 'r' });
|
||||
expect(await discardDesktopHandoff(code)).toBeNull();
|
||||
expect((await redeemHandoff(code, VERIFIER)).ok).toBe(false);
|
||||
const phone = await stashHandoff(PASSWORD_BUNDLE, { challenge: CHALLENGE, state: 's' });
|
||||
expect(await discardDesktopHandoff(phone)).toBeNull();
|
||||
expect((await redeemHandoff(phone, VERIFIER)).ok).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -224,3 +224,62 @@ describe('sso/complete mobile hand-off while the shared store is down (#1066)',
|
||||
expect(JSON.stringify(res.body)).not.toMatch(/"at"|"rt"|mobile_handoff_code/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('/api/auth/sso/complete for the desktop app', () => {
|
||||
const VERIFIER = 'x'.repeat(50);
|
||||
const CHALLENGE = createHash('sha256').update(VERIFIER).digest('base64url');
|
||||
const LISTENER = 'http://127.0.0.1:51234/bulwark-desktop/callback';
|
||||
|
||||
it('parks the tokens under a code, writes nothing in this browser, and only a confirmed code redeems', async () => {
|
||||
exchangeCodeForTokens.mockResolvedValue({ access_token: 'at', refresh_token: 'rt', id_token: 'idt', expires_in: 3600 });
|
||||
pending({ mobile_redirect_uri: LISTENER, mobile_state: 'd-1', mobile_code_challenge: CHALLENGE });
|
||||
const res = await complete();
|
||||
expect(res.status).toBe(200);
|
||||
expect(res.body).toEqual({
|
||||
mobile_handoff_code: expect.stringMatching(/^[0-9a-f]{64}$/),
|
||||
server_url: 'https://mail.example.org',
|
||||
account: '',
|
||||
mobile_redirect_uri: LISTENER,
|
||||
mobile_state: 'd-1',
|
||||
});
|
||||
expect(JSON.stringify(res.body)).not.toMatch(/"at"|"rt"|idt/);
|
||||
expect(jar.has('jmap_rt')).toBe(false);
|
||||
expect(jar.has('sso_pending')).toBe(false);
|
||||
|
||||
expect((await redeemHandoff(res.body.mobile_handoff_code, VERIFIER)).ok).toBe(false);
|
||||
});
|
||||
|
||||
it('names the account for the question: from the id token, or the server\'s session', async () => {
|
||||
const jwt = (claims: object) => `x.${Buffer.from(JSON.stringify(claims)).toString('base64url')}.y`;
|
||||
exchangeCodeForTokens.mockResolvedValue({ access_token: 'at', refresh_token: 'rt', id_token: jwt({ email: 'alice@example.org' }), expires_in: 3600 });
|
||||
pending({ mobile_redirect_uri: LISTENER, mobile_state: 'd-1', mobile_code_challenge: CHALLENGE });
|
||||
expect((await complete()).body.account).toBe('alice@example.org');
|
||||
|
||||
const fetched = vi.fn(async () => new Response(JSON.stringify({ username: 'bob@example.org' }), { status: 200 }));
|
||||
vi.stubGlobal('fetch', fetched);
|
||||
exchangeCodeForTokens.mockResolvedValue({ access_token: 'at', refresh_token: 'rt', expires_in: 3600 });
|
||||
pending({ mobile_redirect_uri: LISTENER, mobile_state: 'd-1', mobile_code_challenge: CHALLENGE });
|
||||
expect((await complete()).body.account).toBe('bob@example.org');
|
||||
expect(fetched).toHaveBeenCalledWith('https://mail.example.org/.well-known/jmap', expect.objectContaining({ headers: { Authorization: 'Bearer at' } }));
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('hands the refresh token itself to the app, which writes its own cookie', async () => {
|
||||
exchangeCodeForTokens.mockResolvedValue({ access_token: 'at', refresh_token: 'rt', id_token: 'idt', expires_in: 3600 });
|
||||
pending({ mobile_redirect_uri: LISTENER, mobile_state: 'd-1', mobile_code_challenge: CHALLENGE, server_id: 'second' });
|
||||
const res = await complete();
|
||||
expect(await confirmHandoff(res.body.mobile_handoff_code)).toBe(true);
|
||||
expect(await redeemHandoff(res.body.mobile_handoff_code, VERIFIER)).toMatchObject({
|
||||
ok: true,
|
||||
state: 'd-1',
|
||||
bundle: { flow: 'desktop-tokens', server_id: 'second', access_token: 'at', refresh_token: 'rt', id_token: 'idt' },
|
||||
});
|
||||
});
|
||||
|
||||
it('fails rather than hands over tokens if a pending cookie has no challenge', async () => {
|
||||
pending({ mobile_redirect_uri: LISTENER, mobile_state: 'd-1' });
|
||||
const res = await complete();
|
||||
expect(res.status).toBe(401);
|
||||
expect(JSON.stringify(res.body)).not.toMatch(/"at"|"rt"/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,8 +66,9 @@ vi.mock('@/lib/auth/session-secret', () => ({
|
||||
hasSessionSecret: () => true,
|
||||
}));
|
||||
|
||||
const configValues = vi.hoisted(() => ({ desktopBrowserSignIn: true } as Record<string, unknown>));
|
||||
vi.mock('@/lib/admin/config-manager', () => ({
|
||||
configManager: { get: () => '' },
|
||||
configManager: { get: (key: string) => (key in configValues ? configValues[key] : '') },
|
||||
}));
|
||||
|
||||
function mockRequest(body: Record<string, unknown>): unknown {
|
||||
@@ -196,3 +197,44 @@ describe('sso start for the mobile app', () => {
|
||||
expect(encrypted.last).not.toHaveProperty('mobile_code_challenge');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sso start for the desktop app', () => {
|
||||
const CHALLENGE = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
|
||||
const LISTENER = 'http://127.0.0.1:51234/bulwark-desktop/callback';
|
||||
const desktop = (extra: Record<string, unknown> = {}) => callRoute({
|
||||
redirect_uri: 'https://mail.example.com/auth/callback',
|
||||
mobile_redirect_uri: LISTENER,
|
||||
mobile_state: 'd-1',
|
||||
...extra,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
encrypted.last = null;
|
||||
});
|
||||
|
||||
it('keeps the loopback target and its challenge for the complete route', async () => {
|
||||
const { status } = await desktop({ mobile_code_challenge: CHALLENGE });
|
||||
expect(status).toBe(200);
|
||||
expect(encrypted.last).toMatchObject({ mobile_redirect_uri: LISTENER, mobile_state: 'd-1', mobile_code_challenge: CHALLENGE });
|
||||
});
|
||||
|
||||
it('refuses it when the instance\'s admin turned it off', async () => {
|
||||
configValues.desktopBrowserSignIn = false;
|
||||
const { status } = await desktop({ mobile_code_challenge: CHALLENGE });
|
||||
configValues.desktopBrowserSignIn = true;
|
||||
expect(status).toBe(403);
|
||||
expect(encrypted.last).toBeNull();
|
||||
});
|
||||
|
||||
it('refuses it without a challenge: tokens would come back for a link', async () => {
|
||||
const { status } = await desktop();
|
||||
expect(status).toBe(400);
|
||||
expect(encrypted.last).toBeNull();
|
||||
});
|
||||
|
||||
it.each(['http://127.0.0.1:51234/other', 'http://localhost:51234/bulwark-desktop/callback', 'http://evil.example/bulwark-desktop/callback'])('refuses %s as a target', async (target) => {
|
||||
const { status } = await desktop({ mobile_code_challenge: CHALLENGE, mobile_redirect_uri: target });
|
||||
expect(status).toBe(400);
|
||||
expect(encrypted.last).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -300,6 +300,9 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
|
||||
// password. For JMAP servers that hand out API tokens, such as Fastmail,
|
||||
// whose API accepts no passwords at all.
|
||||
loginShowTokenLogin: { envVar: 'LOGIN_SHOW_TOKEN_LOGIN', type: 'boolean', defaultValue: false },
|
||||
// The desktop app's "Sign in with your browser". Turn off on a terminal server: another
|
||||
// user there can bind a loopback port and send a link that asks for someone's sign-in.
|
||||
desktopBrowserSignIn: { envVar: 'DESKTOP_BROWSER_SIGN_IN', type: 'boolean', defaultValue: true },
|
||||
// Show the "Remember me" box on the login form. Off (the default) remembers
|
||||
// every sign-in, as long as a SESSION_SECRET makes that possible.
|
||||
loginShowRememberMe: { envVar: 'LOGIN_SHOW_REMEMBER_ME', type: 'boolean', defaultValue: false },
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { logger } from '@/lib/logger';
|
||||
import type { ParkedHandoff } from '@/lib/auth/handoff-store';
|
||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||
import { buildOAuthParams, DEFAULT_CLIENT_ID, getDiscoveryValidator, getRequiredConfig } from '@/lib/oauth/token-exchange';
|
||||
|
||||
const REVOCATION_TIMEOUT_MS = 3000;
|
||||
|
||||
/**
|
||||
* A refresh token that was minted and then not wanted: declined, or refused by the device's
|
||||
* policy. Revoked where the provider has an endpoint for it (Stalwart has none, and there the
|
||||
* token just stops being held). Never throws: nobody is waiting to hear about a failure.
|
||||
*/
|
||||
export async function revokeParkedTokens(parked: ParkedHandoff): Promise<void> {
|
||||
if (parked.flow !== 'desktop-tokens' || !parked.refresh_token) return;
|
||||
try {
|
||||
const options = { fallbackClientId: DEFAULT_CLIENT_ID };
|
||||
const { discoveryUrl } = getRequiredConfig(parked.server_id, options);
|
||||
const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: getDiscoveryValidator(discoveryUrl) });
|
||||
if (!metadata?.revocation_endpoint) return;
|
||||
const params = buildOAuthParams({ token: parked.refresh_token, token_type_hint: 'refresh_token' }, parked.server_id, options);
|
||||
// The body holds the refresh token: no redirect is followed with it.
|
||||
const response = await fetch(metadata.revocation_endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: params.toString(),
|
||||
signal: AbortSignal.timeout(REVOCATION_TIMEOUT_MS),
|
||||
redirect: 'error',
|
||||
});
|
||||
if (!response.ok) logger.warn('Declined token revocation returned error', { status: response.status });
|
||||
} catch (error) {
|
||||
logger.warn('Declined token revocation failed', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { findServerById, findServerByUrl, parseJmapServers, resolveTrustedJmapUrl, type JmapServerEntry } from '@/lib/admin/jmap-servers';
|
||||
import { IS_DESKTOP_BUILD } from '@/lib/desktop/flag';
|
||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||
|
||||
/**
|
||||
* What the desktop app's sign-in routes share (app/api/auth/desktop-handoff). The same
|
||||
* pinning as the other sign-in routes, and the instance admin's switch: on a terminal
|
||||
* server another local user can bind a loopback port, so the admin can turn the hand-off
|
||||
* off there. A device's own `BrowserSignIn` doesn't reach this: it's about the app, and
|
||||
* the other user's link opens in a browser.
|
||||
*/
|
||||
export interface DesktopHandoffRefusal {
|
||||
error: string;
|
||||
status: number;
|
||||
}
|
||||
|
||||
export function desktopHandoffEnabled(): boolean {
|
||||
return !IS_DESKTOP_BUILD && configManager.get<boolean>('desktopBrowserSignIn', true);
|
||||
}
|
||||
|
||||
export type DesktopHandoffServer =
|
||||
| { refusal: DesktopHandoffRefusal }
|
||||
| {
|
||||
entry: JmapServerEntry | undefined;
|
||||
/** What's checked and parked, whatever the body said. */
|
||||
resolved: string;
|
||||
upstream: string;
|
||||
trusted: boolean;
|
||||
/** The user's own server, by `allowCustomJmapEndpoint`. A device's policy may refuse it. */
|
||||
custom: boolean;
|
||||
};
|
||||
|
||||
export async function resolveDesktopHandoffServer(serverUrl: string, serverId: unknown): Promise<DesktopHandoffServer> {
|
||||
await configManager.ensureLoaded();
|
||||
if (!desktopHandoffEnabled()) return { refusal: { error: 'disabled', status: 403 } };
|
||||
if (configManager.get<boolean>('oauthEnabled', false) && configManager.get<boolean>('oauthOnly', false)) {
|
||||
return { refusal: { error: 'oauth_only', status: 403 } };
|
||||
}
|
||||
const configured =
|
||||
configManager.get<string>('jmapServerUrl', '') || process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || '';
|
||||
const list = parseJmapServers(configManager.get<unknown>('jmapServers', []));
|
||||
const requested = typeof serverId === 'string' && serverId ? serverId : null;
|
||||
const entry = findServerById(list, requested) || findServerByUrl(list, serverUrl) || undefined;
|
||||
let resolved: string;
|
||||
let trusted = true;
|
||||
let custom = false;
|
||||
if (entry) {
|
||||
resolved = entry.url;
|
||||
} else {
|
||||
const pinned = resolveTrustedJmapUrl(serverUrl, configured, list);
|
||||
if (pinned) {
|
||||
resolved = pinned;
|
||||
} else if (configManager.get<boolean>('allowCustomJmapEndpoint', false) && (await isPublicHttpUrl(serverUrl))) {
|
||||
resolved = serverUrl;
|
||||
trusted = false;
|
||||
custom = true;
|
||||
} else {
|
||||
return { refusal: { error: 'invalid_server_url', status: 400 } };
|
||||
}
|
||||
}
|
||||
return { entry, resolved, upstream: resolved.replace(/\/+$/, ''), trusted, custom };
|
||||
}
|
||||
+96
-12
@@ -59,9 +59,41 @@ export function isHandoffCode(value: unknown): value is string {
|
||||
return typeof value === 'string' && CODE_RE.test(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* What the desktop app's sign-in parks (app/api/auth/desktop-handoff). The app's redeem
|
||||
* writes its own cookies from this, so these fields never go to anyone else: the mobile
|
||||
* redeem route refuses them.
|
||||
*/
|
||||
export type DesktopHandoffBundle =
|
||||
| {
|
||||
flow: 'desktop-password';
|
||||
server_url: string;
|
||||
server_id: string | null;
|
||||
/** The user's own server, which a device's policy may refuse. */
|
||||
custom: boolean;
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
| {
|
||||
flow: 'desktop-tokens';
|
||||
server_url: string;
|
||||
server_id: string | null;
|
||||
custom: boolean;
|
||||
/** From a typed password, not a provider's page: a password account, as the user knows it. */
|
||||
password_login?: boolean;
|
||||
username?: string;
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
id_token?: string;
|
||||
expires_in: number;
|
||||
signed_in_at: number;
|
||||
};
|
||||
|
||||
export type ParkedHandoff = RedeemBundle | DesktopHandoffBundle;
|
||||
|
||||
interface HandoffRecord {
|
||||
/** Null once redeemed (or tried) or expired. */
|
||||
bundle: RedeemBundle | null;
|
||||
bundle: ParkedHandoff | null;
|
||||
state: string;
|
||||
challenge: string;
|
||||
/** Whether the user confirmed the hand-off; only confirmed codes redeem. */
|
||||
@@ -72,7 +104,7 @@ interface HandoffRecord {
|
||||
}
|
||||
|
||||
export type HandoffRedeemResult =
|
||||
| { ok: true; bundle: RedeemBundle; state: string }
|
||||
| { ok: true; bundle: ParkedHandoff; state: string }
|
||||
| { ok: false; reason: 'invalid' | 'expired' | 'verifier_mismatch' };
|
||||
|
||||
const STORE_KEY = '__bulwarkMobileHandoffStore';
|
||||
@@ -83,8 +115,9 @@ function store(): Map<string, HandoffRecord> {
|
||||
return g[STORE_KEY];
|
||||
}
|
||||
|
||||
// Wipe sign-ins past their expiry and forget tombstones past theirs. Called
|
||||
// on every operation, so memory stays bounded without a timer.
|
||||
// Wipe sign-ins past their expiry and forget tombstones past theirs. Called on
|
||||
// every operation, and by a timer at each record's expiry: a sign-in nobody asks
|
||||
// about again must not stay in memory until someone happens to call in.
|
||||
function sweep(now: number): void {
|
||||
const codes = store();
|
||||
for (const [code, record] of codes) {
|
||||
@@ -93,12 +126,17 @@ function sweep(now: number): void {
|
||||
}
|
||||
}
|
||||
|
||||
function wipeWhenDue(expiresAt: number, now: number): void {
|
||||
const timer = setTimeout(() => sweep(Date.now()), Math.max(expiresAt - now, 0) + 1);
|
||||
timer.unref?.();
|
||||
}
|
||||
|
||||
function s256(verifier: string): string {
|
||||
return createHash('sha256').update(verifier, 'ascii').digest('base64url');
|
||||
}
|
||||
|
||||
function newRecord(
|
||||
bundle: RedeemBundle,
|
||||
bundle: ParkedHandoff,
|
||||
options: { challenge: string; state: string; awaitConfirm?: boolean },
|
||||
now: number,
|
||||
): HandoffRecord {
|
||||
@@ -121,7 +159,7 @@ function confirmed(record: HandoffRecord, now: number): HandoffRecord {
|
||||
}
|
||||
|
||||
/** What a redeem attempt of `record` (spent by the attempt) gets. */
|
||||
function redeemOutcome(record: HandoffRecord, bundle: RedeemBundle | null, verifier: string, now: number): HandoffRedeemResult {
|
||||
function redeemOutcome(record: HandoffRecord, bundle: ParkedHandoff | null, verifier: string, now: number): HandoffRedeemResult {
|
||||
if (!bundle) return { ok: false, reason: record.expiresAt <= now ? 'expired' : 'invalid' };
|
||||
if (!record.confirmed) return { ok: false, reason: 'invalid' };
|
||||
const expected = Buffer.from(record.challenge, 'ascii');
|
||||
@@ -129,7 +167,7 @@ function redeemOutcome(record: HandoffRecord, bundle: RedeemBundle | null, verif
|
||||
if (expected.length !== actual.length || !timingSafeEqual(expected, actual)) {
|
||||
return { ok: false, reason: 'verifier_mismatch' };
|
||||
}
|
||||
if (bundle.flow === 'oauth' && typeof bundle.expires_in === 'number') {
|
||||
if ((bundle.flow === 'oauth' || bundle.flow === 'desktop-tokens') && typeof bundle.expires_in === 'number') {
|
||||
const elapsed = Math.floor((now - record.issuedAt) / 1000);
|
||||
return { ok: true, bundle: { ...bundle, expires_in: Math.max(0, bundle.expires_in - elapsed) }, state: record.state };
|
||||
}
|
||||
@@ -139,6 +177,8 @@ function redeemOutcome(record: HandoffRecord, bundle: RedeemBundle | null, verif
|
||||
// ─── shared store ──────────────────────────────────────────────────────────
|
||||
|
||||
const sharedKey = (code: string) => `handoff:${createHash('sha256').update(code).digest('hex')}`;
|
||||
/** Holds no sign-in, only when it runs out. So a code past that, with its record gone, is told "expired". */
|
||||
const issuedKey = (code: string) => `handoff-issued:${createHash('sha256').update(code).digest('hex')}`;
|
||||
|
||||
function seal(record: HandoffRecord): string {
|
||||
return encryptPayload({ r: record }, 'handoff-store');
|
||||
@@ -153,6 +193,8 @@ function open(sealed: string | null, now: number): HandoffRecord | null {
|
||||
}
|
||||
|
||||
const ttl = (record: HandoffRecord, now: number) => Math.max(record.forgetAt - now, 0) + CLEANUP_MS;
|
||||
/** A record with a sign-in in it stays no longer than the sign-in is good for. */
|
||||
const secretTtl = (record: HandoffRecord, now: number) => Math.max(record.expiresAt - now, 1);
|
||||
|
||||
// ─── API ───────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -161,7 +203,7 @@ const ttl = (record: HandoffRecord, now: number) => Math.max(record.forgetAt - n
|
||||
* `awaitConfirm` the code only becomes redeemable through confirmHandoff.
|
||||
*/
|
||||
export async function stashHandoff(
|
||||
bundle: RedeemBundle,
|
||||
bundle: ParkedHandoff,
|
||||
options: { challenge: string; state: string; awaitConfirm?: boolean },
|
||||
now = Date.now(),
|
||||
): Promise<string> {
|
||||
@@ -170,7 +212,8 @@ export async function stashHandoff(
|
||||
if (isSharedMode()) {
|
||||
// Each code is spent by a single redeem and dies on its own; the create
|
||||
// budget bounds how many one client parks.
|
||||
await getStore().kv.set(sharedKey(code), seal(record), { ttlMs: ttl(record, now) });
|
||||
await getStore().kv.set(sharedKey(code), seal(record), { ttlMs: secretTtl(record, now) });
|
||||
await getStore().kv.set(issuedKey(code), String(record.expiresAt), { ttlMs: ttl(record, now) });
|
||||
return code;
|
||||
}
|
||||
sweep(now);
|
||||
@@ -182,6 +225,7 @@ export async function stashHandoff(
|
||||
codes.delete(oldest);
|
||||
}
|
||||
codes.set(code, record);
|
||||
wipeWhenDue(record.expiresAt, now);
|
||||
return code;
|
||||
}
|
||||
|
||||
@@ -199,16 +243,44 @@ export async function confirmHandoff(code: string, now = Date.now()): Promise<bo
|
||||
if (!record || !record.bundle || record.expiresAt <= now || record.confirmed) return undefined;
|
||||
result.ok = true;
|
||||
return seal(confirmed(record, now));
|
||||
}, { ttlMs: HANDOFF_TTL_MS + TOMBSTONE_MS + CLEANUP_MS });
|
||||
}, { ttlMs: HANDOFF_TTL_MS });
|
||||
if (result.ok) await getStore().kv.set(issuedKey(code), String(now + HANDOFF_TTL_MS), { ttlMs: HANDOFF_TTL_MS + TOMBSTONE_MS + CLEANUP_MS });
|
||||
return result.ok;
|
||||
}
|
||||
sweep(now);
|
||||
const record = store().get(code);
|
||||
if (!record || !record.bundle || record.confirmed) return false;
|
||||
store().set(code, confirmed(record, now));
|
||||
const live = confirmed(record, now);
|
||||
store().set(code, live);
|
||||
wipeWhenDue(live.expiresAt, now);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The user said no to the desktop app, or left. The sign-in goes now, and what it held comes
|
||||
* back once, so a refresh token can be revoked. Only the desktop's: the phone's flow has its own.
|
||||
*/
|
||||
export async function discardDesktopHandoff(code: string, now = Date.now()): Promise<ParkedHandoff | null> {
|
||||
const isDesktop = (bundle: ParkedHandoff | null) => bundle?.flow === 'desktop-password' || bundle?.flow === 'desktop-tokens';
|
||||
if (isSharedMode()) {
|
||||
let taken: ParkedHandoff | null = null;
|
||||
await getStore().kv.update(sharedKey(code), (current) => {
|
||||
taken = null;
|
||||
const record = open(current, now);
|
||||
if (!record || !isDesktop(record.bundle)) return undefined;
|
||||
taken = record.bundle;
|
||||
return seal({ ...record, bundle: null });
|
||||
}, { ttlMs: TOMBSTONE_MS + CLEANUP_MS });
|
||||
return taken as ParkedHandoff | null;
|
||||
}
|
||||
sweep(now);
|
||||
const record = store().get(code);
|
||||
if (!record || !isDesktop(record.bundle)) return null;
|
||||
const taken = record.bundle;
|
||||
record.bundle = null;
|
||||
return taken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand out the sign-in behind `code` when `verifier` matches its challenge.
|
||||
* The code is spent by the attempt, whatever its outcome. `expires_in` of an
|
||||
@@ -219,7 +291,10 @@ export async function redeemHandoff(code: string, verifier: string, now = Date.n
|
||||
const kv = getStore().kv;
|
||||
// Taken in one step: of two attempts, only one sees the sign-in.
|
||||
const record = open(await kv.take(sharedKey(code)), now);
|
||||
if (!record) return { ok: false, reason: 'invalid' };
|
||||
if (!record) {
|
||||
const until = await kv.get(issuedKey(code));
|
||||
return { ok: false, reason: until !== null && Number(until) <= now ? 'expired' : 'invalid' };
|
||||
}
|
||||
const bundle = record.expiresAt > now ? record.bundle : null;
|
||||
// A tombstone goes back, so a late attempt is told "expired". The code is
|
||||
// spent already: if the tombstone can't be written, the answer stands.
|
||||
@@ -249,12 +324,21 @@ export function resetHandoffStoreForTests(): void {
|
||||
// filling the store or hammering the redeem route.
|
||||
const createBudget = createFailureBudget({ name: 'handoff-create', clientMax: 10, globalMax: Infinity });
|
||||
const redeemBudget = createFailureBudget({ name: 'handoff-redeem', clientMax: 10, globalMax: Infinity });
|
||||
const desktopParkBudget = createFailureBudget({ name: 'desktop-handoff-park', clientMax: 10, globalMax: Infinity });
|
||||
|
||||
/** Count one parked sign-in for `client`; false when it is over its limit. */
|
||||
export async function reserveHandoffCreate(client: string, now = Date.now()): Promise<boolean> {
|
||||
return (await createBudget.reserve(client, now)) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* One park for the desktop app, reserved before anything goes upstream: null when `client` is
|
||||
* over. Only failures count, so everyone behind one office address can sign in.
|
||||
*/
|
||||
export function reserveDesktopHandoffPark(client: string, now = Date.now()): Promise<BudgetRefund | null> {
|
||||
return desktopParkBudget.reserve(client, now);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reserve one redeem attempt for `client`: null when it is over its limit,
|
||||
* otherwise a refund to call when the redeem succeeded (only failures count).
|
||||
|
||||
@@ -28,6 +28,74 @@ export async function requestMobileHandoffCodeUrl(opts: {
|
||||
}
|
||||
}
|
||||
|
||||
/** The desktop app's sign-in, before its question: is this password right? Nothing is kept. */
|
||||
export async function checkDesktopSignIn(opts: {
|
||||
serverUrl: string;
|
||||
serverId?: string;
|
||||
username: string;
|
||||
password: string;
|
||||
}): Promise<{ ok: true } | { error: string }> {
|
||||
try {
|
||||
const res = await apiFetch('/api/auth/desktop-handoff/check', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ server_url: opts.serverUrl, server_id: opts.serverId, username: opts.username, password: opts.password }),
|
||||
});
|
||||
if (res.ok) return { ok: true };
|
||||
const data = (await res.json().catch(() => null)) as { error?: unknown } | null;
|
||||
return { error: typeof data?.error === 'string' ? data.error : 'login_failed' };
|
||||
} catch {
|
||||
return { error: 'network_error' };
|
||||
}
|
||||
}
|
||||
|
||||
/** The tab with an SSO sign-in the user hasn't answered went, or said no: it's not kept. */
|
||||
export function discardDesktopSignIn(code: string): void {
|
||||
void apiFetch('/api/auth/desktop-handoff/discard', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code }),
|
||||
keepalive: true,
|
||||
}).catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* The user said yes: parks the sign-in for the app (/api/auth/desktop-handoff/park), as
|
||||
* tokens wherever the server can give them. This browser gets no session out of it.
|
||||
*/
|
||||
export async function parkDesktopSignIn(opts: {
|
||||
challenge: string;
|
||||
state: string;
|
||||
serverUrl: string;
|
||||
serverId?: string;
|
||||
username: string;
|
||||
password: string;
|
||||
totp?: string;
|
||||
redirectUri: string;
|
||||
}): Promise<{ code: string } | { error: string }> {
|
||||
try {
|
||||
const res = await apiFetch('/api/auth/desktop-handoff/park', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
code_challenge: opts.challenge,
|
||||
state: opts.state,
|
||||
server_url: opts.serverUrl,
|
||||
server_id: opts.serverId,
|
||||
username: opts.username,
|
||||
password: opts.password,
|
||||
totp: opts.totp,
|
||||
redirect_uri: opts.redirectUri,
|
||||
}),
|
||||
});
|
||||
const data = (await res.json().catch(() => null)) as { code?: unknown; error?: unknown } | null;
|
||||
if (res.ok && typeof data?.code === 'string' && data.code) return { code: data.code };
|
||||
return { error: typeof data?.error === 'string' ? data.error : 'login_failed' };
|
||||
} catch {
|
||||
return { error: 'network_error' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Confirm an SSO hand-off the server parked (/api/auth/handoff/confirm) and
|
||||
* return the app link with its code. Null when the code is gone (it waited
|
||||
|
||||
@@ -27,6 +27,36 @@ export function mobileHandoffTarget(raw: string | null | undefined): string {
|
||||
: '';
|
||||
}
|
||||
|
||||
/**
|
||||
* The desktop app's loopback listener (RFC 8252), the other kind of target. Only ever
|
||||
* with a code: unlike the phone's link, it has no older builds that take the sign-in in a
|
||||
* fragment, so without a valid challenge this isn't a hand-off at all. The code goes
|
||||
* nowhere but this computer, whichever port a link names, and it's worthless without the
|
||||
* verifier behind the challenge.
|
||||
*/
|
||||
const DESKTOP_TARGET = /^http:\/\/127\.0\.0\.1:([1-9][0-9]{3,4})\/bulwark-desktop\/callback$/;
|
||||
|
||||
export function desktopHandoffTarget(raw: string | null | undefined): string {
|
||||
if (typeof raw !== 'string') return '';
|
||||
const match = DESKTOP_TARGET.exec(raw.trim());
|
||||
return match && Number(match[1]) <= 65535 ? match[0] : '';
|
||||
}
|
||||
|
||||
/** The port a desktop target names, for the question the page asks. */
|
||||
export function desktopHandoffPort(target: string): string {
|
||||
return DESKTOP_TARGET.exec(target)?.[1] ?? '';
|
||||
}
|
||||
|
||||
/** "Not now": ends the app's flow at its listener, so it isn't left waiting. No code in it. */
|
||||
export function desktopHandoffDeclineUrl(target: string, state: string): string {
|
||||
return `${target}?${new URLSearchParams({ error: 'access_denied', state }).toString()}`;
|
||||
}
|
||||
|
||||
/** Either kind of target, as the callback page reads it back. */
|
||||
export function handoffTarget(raw: string | null | undefined): string {
|
||||
return mobileHandoffTarget(raw) || desktopHandoffTarget(raw);
|
||||
}
|
||||
|
||||
/** The URL the browser hands to the app: the target plus the fragment. */
|
||||
export function buildMobileHandoffUrl(target: string, fragment: Record<string, string>): string {
|
||||
return `${target}#${new URLSearchParams(fragment).toString()}`;
|
||||
|
||||
@@ -56,6 +56,7 @@ const configValidators = {
|
||||
loginShowSubtitle: isBoolean,
|
||||
loginShowTotp: isBoolean,
|
||||
loginShowTokenLogin: isBoolean,
|
||||
desktopBrowserSignIn: isBoolean,
|
||||
loginShowRememberMe: isBoolean,
|
||||
loginShowVersion: isBoolean,
|
||||
demoMode: isBoolean,
|
||||
|
||||
@@ -144,79 +144,212 @@ describe('JMAPClient on an approved http server', () => {
|
||||
});
|
||||
|
||||
describe('runDesktopBrowserSignIn', () => {
|
||||
const ENDPOINTS = { authorization_endpoint: 'https://mail.example/authorize', client_id: 'registered-client', scope: 'openid email' };
|
||||
const ENDPOINTS = { authorization_endpoint: 'https://mail.example/authorize', client_id: 'registered-client', scope: 'openid email', issuer: 'https://mail.example' };
|
||||
const REDIRECT = 'http://127.0.0.1:51234/bulwark-desktop/callback';
|
||||
|
||||
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 controls = {
|
||||
begin: vi.fn(async (): Promise<unknown> => ({ ok: true, flowId: 'f1', redirectUri: REDIRECT, state: 'main-state' })),
|
||||
complete: vi.fn(async (_flowId: string, url: string) => (
|
||||
typeof result === 'function' ? (result as (u: string) => unknown)(url) : result
|
||||
)),
|
||||
useScheme: vi.fn(async () => 'bulwark://sign-in/callback'),
|
||||
cancel: vi.fn(),
|
||||
beginPassword: vi.fn(async (): Promise<unknown> => ({ ok: true, host: 'mail.example' })),
|
||||
nextPassword: vi.fn(async (): Promise<unknown> => ({ ok: false, error: 'timeout' })),
|
||||
answerPassword: vi.fn(),
|
||||
outcome: vi.fn(async (): Promise<unknown> => ({ ok: true, code: '', state: 'main-state' })),
|
||||
};
|
||||
setBridge({ version: 1, platform: 'win32', auth: { claim: () => controls } } as unknown as BulwarkDesktopBridge);
|
||||
return controls;
|
||||
}
|
||||
|
||||
const options = { serverUrl: 'https://mail.example', callbackPath: '/en/auth/callback', slot: 1, remember: true, addAccount: false };
|
||||
const redeem = vi.fn(async () => true);
|
||||
const tryPassword = vi.fn(async (): Promise<{ ok: true } | { ok: false; error: 'invalid_credentials' | 'totp_required' | 'other' }> => ({ ok: true }));
|
||||
const options = { serverUrl: 'https://mail.example', slot: 1, remember: true, addAccount: false, fresh: false, redeem, tryPassword };
|
||||
|
||||
beforeEach(() => {
|
||||
redeem.mockClear();
|
||||
tryPassword.mockClear();
|
||||
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 () => {
|
||||
it('runs PKCE with state from main and the loopback redirect, and redeems here, with nothing in an address', 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') };
|
||||
return { ok: true, code: 'the-code', state: 'main-state' };
|
||||
});
|
||||
let readBack = {};
|
||||
redeem.mockImplementationOnce(async () => {
|
||||
readBack = { client: sessionStorage.getItem('oauth_client_id'), slot: sessionStorage.getItem('oauth_cookie_slot'), remember: sessionStorage.getItem('oauth_remember') };
|
||||
return true;
|
||||
});
|
||||
const outcome = await runDesktopBrowserSignIn(options);
|
||||
expect(outcome.kind).toBe('callback');
|
||||
expect(outcome).toEqual({ kind: 'signed-in' });
|
||||
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('redirect_uri')).toBe(REDIRECT);
|
||||
expect(authorize.searchParams.get('state')).toBe('main-state');
|
||||
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(readBack).toEqual({ client: 'registered-client', slot: '1', remember: 'true' });
|
||||
const [server, code, verifier, redirect] = redeem.mock.calls[0] as unknown as string[];
|
||||
expect([server, code, redirect]).toEqual(['https://mail.example', 'the-code', REDIRECT]);
|
||||
// The verifier behind the challenge, kept in this function and nowhere else.
|
||||
expect(verifier.length).toBeGreaterThanOrEqual(43);
|
||||
expect(authorize.toString()).not.toContain(verifier);
|
||||
expect(sessionStorage.getItem('oauth_code_verifier')).toBeNull();
|
||||
expect(sessionStorage.getItem('oauth_client_id')).toBeNull();
|
||||
});
|
||||
|
||||
it('sends nothing to a server the user declined', async () => {
|
||||
it('refuses an answer another issuer signed', 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();
|
||||
authBridge({ ok: true, code: 'c', state: 'main-state', iss: 'https://evil.example' });
|
||||
expect(await runDesktopBrowserSignIn(options)).toEqual({ kind: 'failed', error: 'issuer_mismatch' });
|
||||
expect(redeem).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('asks the provider for a new login when the app says one is due', async () => {
|
||||
const { runDesktopBrowserSignIn } = await import('../browser-sign-in');
|
||||
let authorize = new URL('https://x');
|
||||
authBridge((url: string) => {
|
||||
authorize = new URL(url);
|
||||
return { ok: false, error: 'cancelled' };
|
||||
});
|
||||
await runDesktopBrowserSignIn({ ...options, fresh: true });
|
||||
expect(authorize.searchParams.get('prompt')).toBe('login');
|
||||
});
|
||||
|
||||
it.each(['declined', 'policy', 'invalid'])('takes the no from main: %s, and nothing is asked of the server', async (error) => {
|
||||
const { runDesktopBrowserSignIn } = await import('../browser-sign-in');
|
||||
const controls = authBridge({ ok: false, error: 'cancelled' });
|
||||
controls.begin.mockResolvedValueOnce({ ok: false, error });
|
||||
expect((await runDesktopBrowserSignIn(options)).kind).toBe(error);
|
||||
expect(controls.complete).not.toHaveBeenCalled();
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('says so when the server has no OAuth, and closes the listener', async () => {
|
||||
it('a timeout is its own answer, so the page can say try again', 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();
|
||||
authBridge({ ok: false, error: 'timeout' });
|
||||
expect((await runDesktopBrowserSignIn(options)).kind).toBe('timeout');
|
||||
});
|
||||
|
||||
it('a discovery outage is a failure, not the password page', async () => {
|
||||
const { runDesktopBrowserSignIn } = await import('../browser-sign-in');
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response('{}', { status: 503 })));
|
||||
const controls = authBridge({ ok: true });
|
||||
expect((await runDesktopBrowserSignIn(options)).kind).toBe('failed');
|
||||
expect(controls.cancel).toHaveBeenCalledWith('f1');
|
||||
expect(controls.beginPassword).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('a server with no OAuth gets the page main serves, and each post goes through the login of the app', async () => {
|
||||
const { runDesktopBrowserSignIn } = await import('../browser-sign-in');
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ error: 'no_oauth' }), { status: 404 })));
|
||||
const controls = authBridge({ ok: true });
|
||||
controls.nextPassword
|
||||
.mockResolvedValueOnce({ ok: true, username: 'a', password: 'wrong' })
|
||||
.mockResolvedValueOnce({ ok: true, username: 'a', password: 'right', totp: '123456' });
|
||||
tryPassword.mockResolvedValueOnce({ ok: false, error: 'invalid_credentials' }).mockResolvedValueOnce({ ok: true });
|
||||
expect(await runDesktopBrowserSignIn(options)).toEqual({ kind: 'signed-in' });
|
||||
expect(controls.complete).not.toHaveBeenCalled();
|
||||
expect(tryPassword.mock.calls.map((call) => (call as unknown[])[0])).toEqual([
|
||||
{ ok: true, username: 'a', password: 'wrong' },
|
||||
{ ok: true, username: 'a', password: 'right', totp: '123456' },
|
||||
]);
|
||||
expect(controls.answerPassword.mock.calls).toEqual([
|
||||
['f1', { ok: false, error: 'invalid_credentials' }],
|
||||
['f1', { ok: true }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('a server that refuses loopback goes by the scheme', async () => {
|
||||
const { runDesktopBrowserSignIn } = await import('../browser-sign-in');
|
||||
vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify({ ...ENDPOINTS, use_scheme: true }), { status: 200 })));
|
||||
let authorize = new URL('https://x');
|
||||
const controls = authBridge((url: string) => {
|
||||
authorize = new URL(url);
|
||||
return { ok: true, code: 'c', state: 'main-state' };
|
||||
});
|
||||
await runDesktopBrowserSignIn(options);
|
||||
expect(controls.useScheme).toHaveBeenCalledWith('f1');
|
||||
expect(authorize.searchParams.get('redirect_uri')).toBe('bulwark://sign-in/callback');
|
||||
expect((redeem.mock.calls[0] as unknown as string[])[3]).toBe('bulwark://sign-in/callback');
|
||||
});
|
||||
});
|
||||
|
||||
describe('runInstanceBrowserSignIn', () => {
|
||||
const REDIRECT = 'http://127.0.0.1:51234/bulwark-desktop/callback';
|
||||
|
||||
function bridgeWith(complete: (flowId: string, url: string) => unknown) {
|
||||
const controls = {
|
||||
begin: vi.fn(async () => ({ ok: true, flowId: 'f1', redirectUri: REDIRECT, state: 'main-state' })),
|
||||
complete: vi.fn(async (flowId: string, url: string) => complete(flowId, url)),
|
||||
cancel: vi.fn(),
|
||||
};
|
||||
setBridge({ version: 1, platform: 'win32', auth: { claim: () => controls } } as unknown as BulwarkDesktopBridge);
|
||||
return controls;
|
||||
}
|
||||
|
||||
it('opens the login page of the instance with no secret in the address, and hands back the verifier', async () => {
|
||||
const { runInstanceBrowserSignIn } = await import('../browser-sign-in');
|
||||
let opened = new URL('https://x');
|
||||
bridgeWith((_flow, url) => {
|
||||
opened = new URL(url);
|
||||
return { ok: true, code: 'the-code', state: 'main-state' };
|
||||
});
|
||||
const outcome = await runInstanceBrowserSignIn({ base: 'https://bulwark.example/mail', locale: 'de', fresh: false });
|
||||
expect(opened.origin + opened.pathname).toBe('https://bulwark.example/mail/de/login');
|
||||
expect(opened.searchParams.get('mobile_redirect_uri')).toBe(REDIRECT);
|
||||
expect(opened.searchParams.get('mobile_state')).toBe('main-state');
|
||||
expect(opened.searchParams.get('mobile_code_challenge')).toMatch(/^[A-Za-z0-9_-]{43}$/);
|
||||
expect(opened.searchParams.has('fresh_login')).toBe(false);
|
||||
if (outcome.kind !== 'code') throw new Error(outcome.kind);
|
||||
expect(outcome.code).toBe('the-code');
|
||||
expect(opened.toString()).not.toContain(outcome.verifier);
|
||||
const challenge = Buffer.from(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(outcome.verifier))).toString('base64url');
|
||||
expect(opened.searchParams.get('mobile_code_challenge')).toBe(challenge);
|
||||
});
|
||||
|
||||
it('passes on that a new login is due, and takes a timeout as a timeout', async () => {
|
||||
const { runInstanceBrowserSignIn } = await import('../browser-sign-in');
|
||||
let opened = new URL('https://x');
|
||||
bridgeWith((_flow, url) => {
|
||||
opened = new URL(url);
|
||||
return { ok: false, error: 'timeout' };
|
||||
});
|
||||
expect((await runInstanceBrowserSignIn({ base: 'https://b.example', locale: 'en', fresh: true })).kind).toBe('timeout');
|
||||
expect(opened.searchParams.get('fresh_login')).toBe('1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('who gets the sign-in calls', () => {
|
||||
it('claims them once per bridge, whatever asks after that', async () => {
|
||||
const { claimBrowserSignIn } = await import('../browser-sign-in');
|
||||
const controls = {};
|
||||
const claim = vi.fn(() => controls);
|
||||
setBridge({ version: 1, platform: 'win32', auth: { claim } } as unknown as BulwarkDesktopBridge);
|
||||
expect(claimBrowserSignIn()).toBe(controls);
|
||||
expect(claimBrowserSignIn()).toBe(controls);
|
||||
expect(claim).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('offers nothing in a browser, under an older shell, or with the policy off', async () => {
|
||||
const { browserSignInAvailable } = await import('../browser-sign-in');
|
||||
setBridge(undefined);
|
||||
expect(browserSignInAvailable()).toBe(false);
|
||||
setBridge({ version: 1, platform: 'win32' } as unknown as BulwarkDesktopBridge);
|
||||
expect(browserSignInAvailable()).toBe(false);
|
||||
const claim = () => ({});
|
||||
setBridge({ version: 1, platform: 'win32', auth: { claim } } as unknown as BulwarkDesktopBridge);
|
||||
expect(browserSignInAvailable()).toBe(true);
|
||||
const { EMPTY_POLICY_LAYERS, resolvePolicies } = await import('../policy-schema');
|
||||
const snapshot = resolvePolicies({ ...EMPTY_POLICY_LAYERS, forcedMachine: { BrowserSignIn: false } });
|
||||
setBridge({ version: 1, platform: 'win32', auth: { claim }, policy: { get: () => snapshot, onChange: () => () => {} } } as unknown as BulwarkDesktopBridge);
|
||||
expect(browserSignInAvailable()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// @vitest-environment node
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { NextRequest } from 'next/server';
|
||||
|
||||
/**
|
||||
* What the desktop app asks before it sends someone to a server's own sign-in page: the
|
||||
* authorize endpoint and a client id, and which redirect that server will take.
|
||||
*/
|
||||
|
||||
vi.mock('@/lib/logger', () => ({ logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() } }));
|
||||
vi.mock('@/lib/admin/config-manager', () => ({
|
||||
configManager: { ensureLoaded: async () => {}, get: (key: string, fallback: unknown) => (key === 'allowCustomJmapEndpoint' ? true : fallback) },
|
||||
}));
|
||||
vi.mock('@/lib/oauth/custom-upstream', () => ({
|
||||
resolveCustomUpstream: async (serverUrl: string, clientId: string) => (serverUrl.startsWith('https://') ? { serverUrl, clientId, trusted: false } : null),
|
||||
}));
|
||||
const discoverOAuth = vi.hoisted(() => vi.fn());
|
||||
vi.mock('@/lib/oauth/discovery', () => ({ discoverOAuth }));
|
||||
vi.mock('@/lib/oauth/token-exchange', () => ({ getDiscoveryValidator: () => undefined }));
|
||||
|
||||
const SERVER = 'https://mail.example';
|
||||
const REDIRECT = 'http://127.0.0.1:51234/bulwark-desktop/callback';
|
||||
const METADATA = { issuer: SERVER, authorization_endpoint: `${SERVER}/login`, token_endpoint: `${SERVER}/auth/token` };
|
||||
|
||||
let registrations: { url: string; redirect: string }[];
|
||||
let answer: (redirect: string) => Response;
|
||||
|
||||
function post(body: unknown) {
|
||||
return new NextRequest(`https://app.bulwark.localhost/api/auth/desktop-oauth`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json', 'sec-fetch-site': 'same-origin' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
async function route() {
|
||||
vi.stubEnv('NEXT_PUBLIC_BULWARK_DESKTOP', '1');
|
||||
vi.resetModules();
|
||||
return (await import('@/app/api/auth/desktop-oauth/route')).POST;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
registrations = [];
|
||||
discoverOAuth.mockReset().mockResolvedValue(METADATA);
|
||||
answer = () => new Response(JSON.stringify({ client_id: 'swc1.abc' }), { status: 201 });
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const redirect = (JSON.parse(String(init?.body)) as { redirect_uris: string[] }).redirect_uris[0];
|
||||
registrations.push({ url: String(input), redirect });
|
||||
return answer(redirect);
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('/api/auth/desktop-oauth', () => {
|
||||
it('registers the loopback redirect, with no port: servers ignore the port', async () => {
|
||||
const POST = await route();
|
||||
const res = await POST(post({ serverUrl: SERVER, redirectUri: REDIRECT }));
|
||||
expect(await res.json()).toMatchObject({ authorization_endpoint: `${SERVER}/login`, client_id: 'swc1.abc', issuer: SERVER });
|
||||
expect(registrations).toEqual([{ url: `${SERVER}/auth/register`, redirect: 'http://127.0.0.1/bulwark-desktop/callback' }]);
|
||||
});
|
||||
|
||||
it('uses the registration endpoint the server names', async () => {
|
||||
discoverOAuth.mockResolvedValue({ ...METADATA, registration_endpoint: `${SERVER}/oauth/clients` });
|
||||
const POST = await route();
|
||||
await POST(post({ serverUrl: SERVER, redirectUri: REDIRECT }));
|
||||
expect(registrations[0].url).toBe(`${SERVER}/oauth/clients`);
|
||||
});
|
||||
|
||||
it('says so when a server has no OAuth', async () => {
|
||||
discoverOAuth.mockResolvedValue(null);
|
||||
const POST = await route();
|
||||
const res = await POST(post({ serverUrl: SERVER, redirectUri: REDIRECT }));
|
||||
expect(res.status).toBe(404);
|
||||
expect(await res.json()).toEqual({ error: 'no_oauth' });
|
||||
});
|
||||
|
||||
it('a discovery outage is not the same answer', async () => {
|
||||
discoverOAuth.mockRejectedValue(new Error('down'));
|
||||
const POST = await route();
|
||||
expect((await POST(post({ serverUrl: SERVER, redirectUri: REDIRECT }))).status).toBe(503);
|
||||
});
|
||||
|
||||
it('with registration off, sends the fixed id and the loopback redirect', async () => {
|
||||
answer = () => new Response('{}', { status: 401 });
|
||||
const POST = await route();
|
||||
const body = await (await POST(post({ serverUrl: SERVER, redirectUri: REDIRECT }))).json();
|
||||
expect(body.client_id).toBe('bulwark-desktop');
|
||||
expect(body.use_scheme).toBeUndefined();
|
||||
expect(registrations).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('takes the scheme only from a server that refuses loopback by name and accepts the scheme', async () => {
|
||||
answer = (redirect) => (redirect.startsWith('bulwark:')
|
||||
? new Response(JSON.stringify({ client_id: 'schemed' }), { status: 201 })
|
||||
: new Response(JSON.stringify({ error: 'invalid_redirect_uri' }), { status: 400 }));
|
||||
const POST = await route();
|
||||
const body = await (await POST(post({ serverUrl: SERVER, redirectUri: REDIRECT }))).json();
|
||||
expect(body).toMatchObject({ client_id: 'schemed', use_scheme: true });
|
||||
expect(registrations.map((r) => r.redirect)).toEqual(['http://127.0.0.1/bulwark-desktop/callback', 'bulwark://sign-in/callback']);
|
||||
});
|
||||
|
||||
it('stays on loopback when the server refuses both', async () => {
|
||||
answer = () => new Response(JSON.stringify({ error: 'invalid_redirect_uri' }), { status: 400 });
|
||||
const POST = await route();
|
||||
const body = await (await POST(post({ serverUrl: SERVER, redirectUri: REDIRECT }))).json();
|
||||
expect(body.client_id).toBe('bulwark-desktop');
|
||||
expect(body.use_scheme).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each(['http://localhost:51234/bulwark-desktop/callback', 'https://evil.example/bulwark-desktop/callback', 'bulwark://sign-in/callback'])('refuses %s as the redirect', async (redirectUri) => {
|
||||
const POST = await route();
|
||||
expect((await POST(post({ serverUrl: SERVER, redirectUri }))).status).toBe(400);
|
||||
expect(registrations).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -30,7 +30,7 @@ describe('the schema', () => {
|
||||
'InstanceUrl', 'AllowStandalone', 'AllowedJmapServers', 'AllowInsecureInstance', 'UpdateChannel', 'AutoUpdate',
|
||||
'PluginsMode', 'PluginAllowlist', 'OfflineEnabled', 'AppLockRequired', 'AppLockTimeoutMinutes', 'TelemetryAllowed',
|
||||
'CrashReportsAllowed', 'QuickComposeShortcut', 'AuthServerAllowlist', 'AuthNegotiateDelegateAllowlist',
|
||||
'AutoSelectCertificateForUrls', 'KeystoreAllowedOrigins', 'WipeOfflineData',
|
||||
'AutoSelectCertificateForUrls', 'KeystoreAllowedOrigins', 'BrowserSignIn', 'WipeOfflineData',
|
||||
]);
|
||||
for (const key of POLICY_KEYS) {
|
||||
const { title, description } = POLICY_SCHEMA[key];
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* 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)(\/|$)/;
|
||||
const BLOCKED = /^\/api\/auth\/(pair|handoff|desktop-handoff|impersonate)(\/|$)/;
|
||||
|
||||
export function isDesktopBlockedRoute(pathname: string): boolean {
|
||||
return BLOCKED.test(pathname);
|
||||
|
||||
+154
-48
@@ -1,104 +1,210 @@
|
||||
/**
|
||||
* Sign-in through the system browser in the desktop build, for any Stalwart.
|
||||
* "Sign in with your browser" in the desktop app, so a password manager, passkeys and an
|
||||
* organisation's sign-in all work where they live.
|
||||
*
|
||||
* 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.
|
||||
* Standalone: RFC 8252. The mail server's own authorize page in the system browser, with
|
||||
* the redirect going to a listener in the Electron main process, and this page redeeming
|
||||
* the code with the PKCE verifier it kept. A server with no OAuth at all gets a small page
|
||||
* main serves on a stable loopback port, and what's posted there comes down the app's
|
||||
* normal login path here.
|
||||
*
|
||||
* 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.
|
||||
* Instance: the instance's real login page at its real origin, so saved passwords match.
|
||||
* It parks the sign-in on the instance and sends a one-time code to the same kind of
|
||||
* listener (lib/auth/mobile-handoff.ts). The page redeems it with the verifier.
|
||||
*
|
||||
* The calls are taken once per document, in the first render (claimBrowserSignIn, from
|
||||
* WindowControlsHost): a same-origin frame can reach the bridge, and main can't tell.
|
||||
*
|
||||
* Google and Entra can't work in standalone. Their clients are registered by whoever owns
|
||||
* them, and there nobody does. Those need an instance.
|
||||
*/
|
||||
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { generateCodeChallenge, generateCodeVerifier, generateState } from '@/lib/oauth/pkce';
|
||||
import { IS_LITE } from '@/lib/lite';
|
||||
import { generateCodeChallenge, generateCodeVerifier } from '@/lib/oauth/pkce';
|
||||
import { getDesktopBridge } from './bridge';
|
||||
import { desktopPolicyValue } from './policy';
|
||||
import type { DesktopBrowserSignInControls, DesktopPasswordSignInAnswer, DesktopPasswordSignInAttempt } from './types';
|
||||
|
||||
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: 'signed-in' }
|
||||
/** Instance mode: what to redeem. */
|
||||
| { kind: 'code'; code: string; verifier: string }
|
||||
| { kind: 'declined' }
|
||||
| { kind: 'invalid' }
|
||||
| { kind: 'policy' }
|
||||
| { kind: 'cancelled' }
|
||||
/** The user said no in the browser. */
|
||||
| { kind: 'denied' }
|
||||
| { kind: 'timeout' }
|
||||
| { kind: 'no_browser' }
|
||||
| { kind: 'failed'; error: string };
|
||||
|
||||
interface DesktopOAuthAnswer {
|
||||
authorization_endpoint: string;
|
||||
client_id: string;
|
||||
scope: string;
|
||||
issuer?: string;
|
||||
use_scheme?: boolean;
|
||||
}
|
||||
|
||||
let controls: DesktopBrowserSignInControls | null = null;
|
||||
let claimedFrom: unknown;
|
||||
let currentFlow: string | null = null;
|
||||
|
||||
/** Before any frame exists. Whoever comes after this gets nothing from the preload. */
|
||||
export function claimBrowserSignIn(): DesktopBrowserSignInControls | null {
|
||||
const bridge = getDesktopBridge();
|
||||
if (claimedFrom !== bridge) {
|
||||
claimedFrom = bridge;
|
||||
controls = bridge?.auth?.claim?.() ?? null;
|
||||
}
|
||||
return controls;
|
||||
}
|
||||
|
||||
/** Whether this page offers it. In a browser there's no bridge, and Lite has no server to park on. */
|
||||
export function browserSignInAvailable(): boolean {
|
||||
return !!getDesktopBridge()?.auth;
|
||||
return !IS_LITE && !!claimBrowserSignIn() && desktopPolicyValue('BrowserSignIn') === true;
|
||||
}
|
||||
|
||||
/** The Cancel button, and leaving the page. */
|
||||
export function cancelBrowserSignIn(): void {
|
||||
if (currentFlow) controls?.cancel(currentFlow);
|
||||
currentFlow = null;
|
||||
}
|
||||
|
||||
function failure(error: string): DesktopBrowserSignInOutcome {
|
||||
if (error === 'cancelled') return { kind: 'cancelled' };
|
||||
if (error === 'denied') return { kind: 'denied' };
|
||||
if (error === 'timeout') return { kind: 'timeout' };
|
||||
if (error === 'no_browser') return { kind: 'no_browser' };
|
||||
if (error === 'policy') return { kind: 'policy' };
|
||||
if (error === 'declined') return { kind: 'declined' };
|
||||
if (error === 'invalid') return { kind: 'invalid' };
|
||||
return { kind: 'failed', error };
|
||||
}
|
||||
|
||||
/** Instance mode. The caller redeems the code (loginWithDesktopHandoff in the auth store). */
|
||||
export async function runInstanceBrowserSignIn(options: { base: string; locale: string; fresh: boolean }): Promise<DesktopBrowserSignInOutcome> {
|
||||
const auth = claimBrowserSignIn();
|
||||
if (!auth) return { kind: 'failed', error: 'unavailable' };
|
||||
const begun = await auth.begin({ kind: 'instance' });
|
||||
if (!begun.ok) return failure(begun.error);
|
||||
currentFlow = begun.flowId;
|
||||
try {
|
||||
const verifier = generateCodeVerifier();
|
||||
const url = new URL(`${options.base}/${options.locale}/login`);
|
||||
url.searchParams.set('mobile_redirect_uri', begun.redirectUri);
|
||||
url.searchParams.set('mobile_state', begun.state);
|
||||
url.searchParams.set('mobile_code_challenge', await generateCodeChallenge(verifier));
|
||||
if (options.fresh) url.searchParams.set('fresh_login', '1');
|
||||
const result = await auth.complete(begun.flowId, url.toString());
|
||||
if (!result.ok) return failure(result.error);
|
||||
return { kind: 'code', code: result.code, verifier };
|
||||
} catch (error) {
|
||||
auth.cancel(begun.flowId);
|
||||
return { kind: 'failed', error: error instanceof Error ? error.name : 'error' };
|
||||
} finally {
|
||||
currentFlow = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** The page main serves: each post goes through the app's own login, until one works. */
|
||||
async function runPasswordPage(
|
||||
auth: DesktopBrowserSignInControls,
|
||||
flowId: string,
|
||||
tryPassword: (attempt: Extract<DesktopPasswordSignInAttempt, { ok: true }>) => Promise<DesktopPasswordSignInAnswer>,
|
||||
): Promise<DesktopBrowserSignInOutcome> {
|
||||
const started = await auth.beginPassword(flowId);
|
||||
if (!started.ok) return failure(started.error);
|
||||
for (;;) {
|
||||
const attempt = await auth.nextPassword(flowId);
|
||||
if (!attempt.ok) return attempt.error === 'done' ? { kind: 'signed-in' } : failure(attempt.error);
|
||||
const answer = await tryPassword(attempt);
|
||||
auth.answerPassword(flowId, answer);
|
||||
if (answer.ok) {
|
||||
const ended = await auth.outcome(flowId);
|
||||
return ended.ok ? { kind: 'signed-in' } : failure(ended.error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Standalone. Only referenced from behind the desktop build's flag, so no other bundle holds the route. */
|
||||
export async function runDesktopBrowserSignIn(options: {
|
||||
serverUrl: string;
|
||||
callbackPath: string;
|
||||
slot: number;
|
||||
remember: boolean;
|
||||
addAccount: boolean;
|
||||
fresh: boolean;
|
||||
/** Redeems the code: loginWithOAuth in the auth store. */
|
||||
redeem: (serverUrl: string, code: string, verifier: string, redirectUri: string) => Promise<boolean>;
|
||||
tryPassword: (attempt: Extract<DesktopPasswordSignInAttempt, { ok: true }>) => Promise<DesktopPasswordSignInAnswer>;
|
||||
}): Promise<DesktopBrowserSignInOutcome> {
|
||||
const bridge = getDesktopBridge();
|
||||
if (!bridge?.auth) return { kind: 'unavailable' };
|
||||
const { serverUrl, callbackPath, slot, remember, addAccount } = options;
|
||||
const auth = claimBrowserSignIn();
|
||||
if (!auth) return { kind: 'failed', error: 'unavailable' };
|
||||
const { serverUrl, slot, remember, addAccount, fresh, redeem, tryPassword } = options;
|
||||
|
||||
const prepared = await bridge.servers?.prepare(serverUrl);
|
||||
if (prepared && !prepared.ok) return { kind: 'declined' };
|
||||
|
||||
const { flowId, redirectUri } = await bridge.auth.beginBrowserSignIn();
|
||||
const begun = await auth.begin({ kind: 'oauth', serverUrl });
|
||||
if (!begun.ok) return failure(begun.error);
|
||||
const { flowId } = begun;
|
||||
currentFlow = flowId;
|
||||
try {
|
||||
const res = await apiFetch('/api/auth/desktop-oauth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ serverUrl, redirectUri }),
|
||||
body: JSON.stringify({ serverUrl, redirectUri: begun.redirectUri }),
|
||||
});
|
||||
if (res.status === 404) {
|
||||
const body = (await res.json().catch(() => null)) as { error?: string } | null;
|
||||
if (body?.error === 'no_oauth') return await runPasswordPage(auth, flowId, tryPassword);
|
||||
}
|
||||
if (!res.ok) {
|
||||
bridge.auth.cancelBrowserSignIn(flowId);
|
||||
return { kind: 'unavailable' };
|
||||
auth.cancel(flowId);
|
||||
return { kind: 'failed', error: 'discovery' };
|
||||
}
|
||||
const answer = (await res.json()) as DesktopOAuthAnswer;
|
||||
let redirectUri = begun.redirectUri;
|
||||
if (answer.use_scheme) {
|
||||
const scheme = await auth.useScheme(flowId);
|
||||
if (!scheme) {
|
||||
auth.cancel(flowId);
|
||||
return { kind: 'failed', error: 'scheme' };
|
||||
}
|
||||
redirectUri = scheme;
|
||||
}
|
||||
|
||||
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('state', begun.state);
|
||||
authorizeUrl.searchParams.set('code_challenge', await generateCodeChallenge(verifier));
|
||||
authorizeUrl.searchParams.set('code_challenge_method', 'S256');
|
||||
if (addAccount) authorizeUrl.searchParams.set('prompt', 'select_account');
|
||||
if (fresh) authorizeUrl.searchParams.set('prompt', 'login');
|
||||
|
||||
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' };
|
||||
const result = await auth.complete(flowId, authorizeUrl.toString());
|
||||
if (!result.ok) return failure(result.error);
|
||||
// RFC 9207: the answer names who issued it. Main has already checked the state.
|
||||
if (result.iss && answer.issuer && result.iss !== answer.issuer) return { kind: 'failed', error: 'issuer_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');
|
||||
// What the auth store's exchange reads. The code never goes near an address bar.
|
||||
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}` };
|
||||
try {
|
||||
return (await redeem(serverUrl, result.code, verifier, redirectUri)) ? { kind: 'signed-in' } : { kind: 'failed', error: 'exchange' };
|
||||
} finally {
|
||||
sessionStorage.removeItem(DESKTOP_CLIENT_ID_KEY);
|
||||
}
|
||||
} catch (error) {
|
||||
bridge.auth.cancelBrowserSignIn(flowId);
|
||||
return { kind: 'failed', error: error instanceof Error ? error.message : String(error) };
|
||||
auth.cancel(flowId);
|
||||
return { kind: 'failed', error: error instanceof Error ? error.name : 'error' };
|
||||
} finally {
|
||||
currentFlow = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,19 @@ export function noteComposerLanguage(key: string, language: string | null): void
|
||||
export function shellLabels(t: (key: string, values?: Record<string, string>) => string): Record<string, string> {
|
||||
return {
|
||||
...updateLabels(t),
|
||||
signInDone: t('desktop_browser_sign_in.page_done'),
|
||||
signInFailed: t('desktop_browser_sign_in.page_failed'),
|
||||
signInLinkUsed: t('desktop_browser_sign_in.page_link_used'),
|
||||
signInDeclined: t('desktop_browser_sign_in.page_declined'),
|
||||
signInPageTitle: t('desktop_browser_sign_in.page_title', { server: '{server}' }),
|
||||
signInPageIntro: t('desktop_browser_sign_in.page_intro', { server: '{server}' }),
|
||||
signInUsername: t('desktop_browser_sign_in.page_username'),
|
||||
signInPassword: t('desktop_browser_sign_in.page_password'),
|
||||
signInCode: t('desktop_browser_sign_in.page_code'),
|
||||
signInSubmit: t('desktop_browser_sign_in.page_submit'),
|
||||
signInWrongPassword: t('desktop_browser_sign_in.page_wrong_password'),
|
||||
signInCodeNeeded: t('desktop_browser_sign_in.page_code_needed'),
|
||||
signInNoAnswer: t('desktop_browser_sign_in.page_no_answer'),
|
||||
...certificateLabels(t),
|
||||
editCancel: t('desktopEdit.cancel'),
|
||||
editDiscardTitle: t('desktopEdit.discard_title', { name: '{name}' }),
|
||||
|
||||
@@ -76,6 +76,7 @@ export interface PolicyValues {
|
||||
AuthNegotiateDelegateAllowlist: string | null;
|
||||
AutoSelectCertificateForUrls: CertificateRule[];
|
||||
KeystoreAllowedOrigins: string[];
|
||||
BrowserSignIn: boolean;
|
||||
WipeOfflineData: string | null;
|
||||
}
|
||||
|
||||
@@ -384,6 +385,18 @@ export const POLICY_SCHEMA: Record<PolicyKey, PolicyDefinition> = {
|
||||
de: 'Die Bulwark-Server, nach https-Adresse, deren Plugins mit den Zertifikaten im Speicher des Betriebssystems signieren und entschlüsseln dürfen, ohne dass der Benutzer für jede Website gefragt wird. Eine pro Zeile.',
|
||||
},
|
||||
},
|
||||
BrowserSignIn: {
|
||||
category: 'signIn',
|
||||
type: 'boolean',
|
||||
default: true,
|
||||
onInvalid: false,
|
||||
sample: true,
|
||||
title: { en: 'Offer sign-in through the browser', de: 'Anmeldung über den Browser anbieten' },
|
||||
description: {
|
||||
en: 'When enabled or not configured, the login page offers "Sign in with your browser", so people can use their password manager, passkeys and your single sign-on there. When disabled, the button is gone and the app refuses the flow. The form in the app stays, unless the server only allows OAuth.',
|
||||
de: 'Wenn aktiviert oder nicht konfiguriert, bietet die Anmeldeseite „Mit Ihrem Browser anmelden“ an, damit Benutzer dort ihren Passwort-Manager, Passkeys und Ihr Single Sign-on verwenden können. Wenn deaktiviert, fehlt die Schaltfläche und die App lehnt den Vorgang ab. Das Formular in der App bleibt, sofern der Server nicht nur OAuth erlaubt.',
|
||||
},
|
||||
},
|
||||
WipeOfflineData: {
|
||||
category: 'security',
|
||||
type: 'string',
|
||||
@@ -828,6 +841,7 @@ export const POLICY_CONSUMERS: Record<PolicyKey, PolicyConsumer> = {
|
||||
QuickComposeShortcut: { repo: 'shell', file: 'src/main/features/os-quick-compose.ts' },
|
||||
AutoSelectCertificateForUrls: { repo: 'shell', file: 'src/main/features/client-certificates.ts' },
|
||||
KeystoreAllowedOrigins: { repo: 'shell', file: 'src/main/features/policies.ts' },
|
||||
BrowserSignIn: { repo: 'shell', file: 'src/main/features/browser-sign-in.ts' },
|
||||
};
|
||||
|
||||
/** The part the embedded server enforces. Sent to it by message. */
|
||||
|
||||
+37
-7
@@ -190,16 +190,46 @@ export interface DesktopLifecycleBridge {
|
||||
onFlushRequest: (handler: () => Promise<DesktopFlushAnswer | unknown>) => () => void;
|
||||
}
|
||||
|
||||
export type DesktopBrowserSignInResult =
|
||||
| { ok: true; code: string; state: string }
|
||||
/** Standalone: a mail server's own OAuth. Instance: the instance's own login page. */
|
||||
export type DesktopBrowserSignInRequest = { kind: 'oauth'; serverUrl: string } | { kind: 'instance' };
|
||||
|
||||
export type DesktopBrowserSignInBegin =
|
||||
| { ok: true; flowId: string; redirectUri: string; state: string }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export type DesktopBrowserSignInResult =
|
||||
| { ok: true; code: string; state: string; iss?: string }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export type DesktopPasswordSignInStart = { ok: true; host: string } | { ok: false; error: string };
|
||||
|
||||
export type DesktopPasswordSignInAttempt =
|
||||
| { ok: true; username: string; password: string; totp?: string }
|
||||
| { ok: false; error: string };
|
||||
|
||||
export type DesktopPasswordSignInAnswer =
|
||||
| { ok: true }
|
||||
| { ok: false; error: 'invalid_credentials' | 'totp_required' | 'other' };
|
||||
|
||||
/** Sign-in through the system browser. See lib/desktop/browser-sign-in.ts. */
|
||||
export interface DesktopBrowserSignInControls {
|
||||
/** A short-lived loopback listener for one sign-in. Only from a click. */
|
||||
begin: (request: DesktopBrowserSignInRequest) => Promise<DesktopBrowserSignInBegin>;
|
||||
/** Opens the system browser on `url` and waits for the redirect. */
|
||||
complete: (flowId: string, url: string) => Promise<DesktopBrowserSignInResult>;
|
||||
/** The server refused loopback and took the `bulwark:` scheme. */
|
||||
useScheme: (flowId: string) => Promise<string | null>;
|
||||
cancel: (flowId: string) => void;
|
||||
/** The server has no OAuth: a page main serves on a stable loopback port. */
|
||||
beginPassword: (flowId: string) => Promise<DesktopPasswordSignInStart>;
|
||||
nextPassword: (flowId: string) => Promise<DesktopPasswordSignInAttempt>;
|
||||
answerPassword: (flowId: string, answer: DesktopPasswordSignInAnswer) => void;
|
||||
outcome: (flowId: string) => Promise<DesktopBrowserSignInResult>;
|
||||
}
|
||||
|
||||
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;
|
||||
/** Once per document, in the first render: a same-origin frame can reach this bridge. */
|
||||
claim: () => DesktopBrowserSignInControls | null;
|
||||
}
|
||||
|
||||
export type DesktopPluginInstallResult =
|
||||
|
||||
@@ -89,6 +89,7 @@ export function applyLiteConfig(raw: unknown, defaults?: { jmapServerUrl?: strin
|
||||
loginShowSubtitle: bool(input.loginShowSubtitle, true),
|
||||
loginShowTotp: bool(input.loginShowTotp, true),
|
||||
loginShowTokenLogin: bool(input.loginShowTokenLogin, false),
|
||||
desktopBrowserSignIn: false,
|
||||
loginShowRememberMe: bool(input.loginShowRememberMe, false),
|
||||
loginShowVersion: bool(input.loginShowVersion, true),
|
||||
demoMode: bool(input.demoMode, false),
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface OAuthMetadata {
|
||||
revocation_endpoint?: string;
|
||||
end_session_endpoint?: string;
|
||||
scopes_supported?: string[];
|
||||
registration_endpoint?: string;
|
||||
}
|
||||
|
||||
// Validates that a discovered endpoint URL is safe to follow. Server-side
|
||||
@@ -148,6 +149,8 @@ async function attemptDiscovery(
|
||||
token_endpoint: data.token_endpoint,
|
||||
revocation_endpoint: data.revocation_endpoint,
|
||||
end_session_endpoint: data.end_session_endpoint,
|
||||
// Not followed from here: the one caller checks it itself (app/api/auth/desktop-oauth).
|
||||
...(typeof data.registration_endpoint === 'string' ? { registration_endpoint: data.registration_endpoint } : {}),
|
||||
...(Array.isArray(data.scopes_supported)
|
||||
? { scopes_supported: data.scopes_supported.filter((s: unknown): s is string => typeof s === 'string') }
|
||||
: {}),
|
||||
|
||||
+30
-4
@@ -6873,14 +6873,40 @@
|
||||
"blocked_minutes": "محاولات كثيرة جدًا. حاول بعد {count, plural, zero {# دقيقة} one {دقيقة واحدة} two {دقيقتين} few {# دقائق} many {# دقيقة} other {# دقيقة}}."
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "سجّل الدخول باستخدام المتصفح",
|
||||
"browser_waiting": "أكمل تسجيل الدخول في المتصفح",
|
||||
"browser_unavailable": "لا يتيح هذا الخادم تسجيل الدخول عبر المتصفح. استخدم كلمة المرور بدلًا من ذلك.",
|
||||
"browser_failed": "لم ينجح تسجيل الدخول عبر المتصفح. حاول مرة أخرى.",
|
||||
"server_needed": "أدخل عنوان الخادم أولًا.",
|
||||
"server_declined": "لم تسمح بهذا الخادم، لذا لم يُرسل إليه أي شيء.",
|
||||
"server_invalid": "عنوان الخادم هذا غير صالح."
|
||||
},
|
||||
"desktop_browser_sign_in": {
|
||||
"button": "تسجيل الدخول عبر المتصفح…",
|
||||
"waiting": "في انتظار المتصفح…",
|
||||
"waiting_hint": "أكمل تسجيل الدخول هناك. يعمل مدير كلمات المرور لديك في المتصفح.",
|
||||
"cancel": "إلغاء",
|
||||
"or_here": "أو سجّل الدخول هنا",
|
||||
"timed_out": "استغرق تسجيل الدخول في المتصفح وقتًا طويلًا. حاول مرة أخرى.",
|
||||
"no_browser": "تعذر على Bulwark فتح المتصفح. حاول مرة أخرى أو سجّل الدخول هنا.",
|
||||
"failed": "تعذر تسجيل الدخول عبر المتصفح. حاول مرة أخرى.",
|
||||
"handoff_banner": "طلب برنامج على هذا الكمبيوتر تسجيل الدخول هذا لتطبيق Bulwark. يبقى هذا المتصفح خارج Bulwark. إذا لم تبدأ ذلك بنفسك، فأغلق علامة التبويب هذه.",
|
||||
"consent_title": "هل تريد تسجيل الدخول إلى تطبيق Bulwark؟",
|
||||
"consent_description": "يطلب برنامج على هذا الكمبيوتر، على المنفذ {port}، تسجيل دخول {username}. تابع فقط إذا كنت قد ضغطت للتو على «تسجيل الدخول عبر المتصفح…» في تطبيق Bulwark هنا. وإلا فقد يحاول شخص ما الوصول إلى حسابك: اختر ليس الآن.",
|
||||
"consent_confirm": "تسجيل الدخول إلى التطبيق",
|
||||
"consent_cancel": "ليس الآن",
|
||||
"page_done": "يمكنك إغلاق علامة التبويب هذه والعودة إلى Bulwark.",
|
||||
"page_failed": "تعذر تسجيل الدخول. عد إلى Bulwark وحاول مرة أخرى.",
|
||||
"page_link_used": "يعمل هذا الرابط مرة واحدة فقط. عد إلى Bulwark وابدأ من جديد.",
|
||||
"page_title": "تسجيل الدخول إلى {server}",
|
||||
"page_intro": "هذه الصفحة من تطبيق Bulwark على هذا الكمبيوتر. تذهب كلمة مرورك إلى {server} فقط.",
|
||||
"page_username": "عنوان البريد الإلكتروني أو اسم المستخدم",
|
||||
"page_password": "كلمة المرور",
|
||||
"page_code": "رمز المصادقة الثنائية",
|
||||
"page_submit": "تسجيل الدخول",
|
||||
"page_wrong_password": "لم يقبل الخادم اسم المستخدم وكلمة المرور هذين.",
|
||||
"page_code_needed": "يحتاج هذا الحساب أيضًا إلى رمز المصادقة الثنائية. أدخل كلمة المرور مرة أخرى والرمز.",
|
||||
"page_no_answer": "تعذر على Bulwark تسجيل الدخول. تحقق من إمكانية الوصول إلى الخادم وحاول مرة أخرى.",
|
||||
"declined": "رفضت في المتصفح، لذا لم يتم تسجيل الدخول.",
|
||||
"too_many": "محاولات تسجيل دخول كثيرة جدًا من هذه الشبكة. انتظر بضع دقائق وحاول مرة أخرى.",
|
||||
"page_declined": "لم يتم تسليم أي شيء. يمكنك إغلاق علامة التبويب هذه."
|
||||
},
|
||||
"desktop_updates": {
|
||||
"title": "التحديثات",
|
||||
"version_label": "Bulwark {version}",
|
||||
|
||||
+30
-4
@@ -6873,14 +6873,40 @@
|
||||
"blocked_minutes": "Massa intents. Torneu-ho a provar d'aquí a {count, plural, one {# minut} other {# minuts}}."
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"desktop_browser_sign_in": {
|
||||
"button": "Inicia la sessió amb el navegador…",
|
||||
"waiting": "S’està esperant el navegador…",
|
||||
"waiting_hint": "Acaba d’iniciar la sessió allà. El teu gestor de contrasenyes funciona al navegador.",
|
||||
"cancel": "Cancel·la",
|
||||
"or_here": "o inicia la sessió aquí",
|
||||
"timed_out": "L’inici de sessió al navegador ha trigat massa. Torna-ho a provar.",
|
||||
"no_browser": "Bulwark no ha pogut obrir el navegador. Torna-ho a provar o inicia la sessió aquí.",
|
||||
"failed": "No s’ha pogut iniciar la sessió a través del navegador. Torna-ho a provar.",
|
||||
"handoff_banner": "Un programa d’aquest ordinador ha demanat aquest inici de sessió per a l’aplicació Bulwark. Aquest navegador continua sense sessió a Bulwark. Si no ho has començat tu, tanca aquesta pestanya.",
|
||||
"consent_title": "Vols iniciar la sessió a l’aplicació Bulwark?",
|
||||
"consent_description": "Un programa d’aquest ordinador, al port {port}, demana l’inici de sessió de {username}. Continua només si acabes de prémer «Inicia la sessió amb el navegador…» a l’aplicació Bulwark d’aquí. Si no, potser algú intenta entrar al teu compte: tria Ara no.",
|
||||
"consent_confirm": "Inicia la sessió a l’aplicació",
|
||||
"consent_cancel": "Ara no",
|
||||
"page_done": "Pots tancar aquesta pestanya i tornar a Bulwark.",
|
||||
"page_failed": "No s’ha pogut iniciar la sessió. Torna a Bulwark i torna-ho a provar.",
|
||||
"page_link_used": "Aquest enllaç només funciona una vegada. Torna a Bulwark i comença de nou.",
|
||||
"page_title": "Inicia la sessió a {server}",
|
||||
"page_intro": "Aquesta pàgina ve de l’aplicació Bulwark d’aquest ordinador. La teva contrasenya va a {server} i enlloc més.",
|
||||
"page_username": "Adreça electrònica o nom d’usuari",
|
||||
"page_password": "Contrasenya",
|
||||
"page_code": "Codi de dos factors",
|
||||
"page_submit": "Inicia la sessió",
|
||||
"page_wrong_password": "El servidor no ha acceptat aquest nom d’usuari i aquesta contrasenya.",
|
||||
"page_code_needed": "Aquest compte també necessita un codi de dos factors. Torna a escriure la contrasenya i el codi.",
|
||||
"page_no_answer": "Bulwark no ha pogut iniciar la sessió. Comprova que el servidor sigui accessible i torna-ho a provar.",
|
||||
"declined": "Has dit que no al navegador, així que no s’ha iniciat cap sessió.",
|
||||
"too_many": "Massa intents d’inici de sessió des d’aquesta xarxa. Espera uns minuts i torna-ho a provar.",
|
||||
"page_declined": "No s’ha lliurat res. Pots tancar aquesta pestanya."
|
||||
},
|
||||
"desktop_updates": {
|
||||
"title": "Actualitzacions",
|
||||
"version_label": "Bulwark {version}",
|
||||
|
||||
+30
-4
@@ -6873,14 +6873,40 @@
|
||||
"blocked_minutes": "Příliš mnoho pokusů. Zkuste to za {count, plural, one {# minutu} few {# minuty} many {# minuty} other {# minut}}."
|
||||
},
|
||||
"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á."
|
||||
},
|
||||
"desktop_browser_sign_in": {
|
||||
"button": "Přihlásit se v prohlížeči…",
|
||||
"waiting": "Čekání na prohlížeč…",
|
||||
"waiting_hint": "Dokončete přihlášení tam. Váš správce hesel funguje v prohlížeči.",
|
||||
"cancel": "Zrušit",
|
||||
"or_here": "nebo se přihlaste zde",
|
||||
"timed_out": "Přihlášení v prohlížeči trvalo příliš dlouho. Zkuste to znovu.",
|
||||
"no_browser": "Bulwark nemohl otevřít prohlížeč. Zkuste to znovu nebo se přihlaste zde.",
|
||||
"failed": "Přihlášení přes prohlížeč se nezdařilo. Zkuste to znovu.",
|
||||
"handoff_banner": "Program v tomto počítači požádal o toto přihlášení pro aplikaci Bulwark. Tento prohlížeč zůstane z Bulwarku odhlášený. Pokud jste to nespustili vy, zavřete tuto kartu.",
|
||||
"consent_title": "Přihlásit se do aplikace Bulwark?",
|
||||
"consent_description": "Program v tomto počítači, na portu {port}, žádá o přihlášení pro {username}. Pokračujte, jen pokud jste právě klikli na „Přihlásit se v prohlížeči…“ v aplikaci Bulwark v tomto počítači. Pokud ne, možná se někdo snaží dostat do vašeho účtu: zvolte Teď ne.",
|
||||
"consent_confirm": "Přihlásit se do aplikace",
|
||||
"consent_cancel": "Teď ne",
|
||||
"page_done": "Tuto kartu můžete zavřít a vrátit se do Bulwarku.",
|
||||
"page_failed": "Přihlášení se nezdařilo. Vraťte se do Bulwarku a zkuste to znovu.",
|
||||
"page_link_used": "Tento odkaz funguje jen jednou. Vraťte se do Bulwarku a začněte znovu.",
|
||||
"page_title": "Přihlásit se k {server}",
|
||||
"page_intro": "Tato stránka pochází z aplikace Bulwark v tomto počítači. Vaše heslo jde na {server} a nikam jinam.",
|
||||
"page_username": "E-mailová adresa nebo uživatelské jméno",
|
||||
"page_password": "Heslo",
|
||||
"page_code": "Dvoufázový kód",
|
||||
"page_submit": "Přihlásit se",
|
||||
"page_wrong_password": "Server toto uživatelské jméno a heslo nepřijal.",
|
||||
"page_code_needed": "Tento účet potřebuje také dvoufázový kód. Zadejte znovu heslo a kód.",
|
||||
"page_no_answer": "Bulwark se nemohl přihlásit. Zkontrolujte, zda je server dostupný, a zkuste to znovu.",
|
||||
"declined": "V prohlížeči jste odmítli, takže k přihlášení nedošlo.",
|
||||
"too_many": "Příliš mnoho pokusů o přihlášení z této sítě. Počkejte několik minut a zkuste to znovu.",
|
||||
"page_declined": "Nic nebylo předáno. Tuto kartu můžete zavřít."
|
||||
},
|
||||
"desktop_updates": {
|
||||
"title": "Aktualizace",
|
||||
"version_label": "Bulwark {version}",
|
||||
|
||||
+30
-4
@@ -6873,14 +6873,40 @@
|
||||
"blocked_minutes": "For mange forsøg. Prøv igen om {count, plural, one {# minut} other {# minutter}}."
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"desktop_browser_sign_in": {
|
||||
"button": "Log ind med din browser…",
|
||||
"waiting": "Venter på din browser…",
|
||||
"waiting_hint": "Afslut login der. Din adgangskodeadministrator virker i din browser.",
|
||||
"cancel": "Annuller",
|
||||
"or_here": "eller log ind her",
|
||||
"timed_out": "Login i browseren tog for lang tid. Prøv igen.",
|
||||
"no_browser": "Bulwark kunne ikke åbne din browser. Prøv igen, eller log ind her.",
|
||||
"failed": "Login via din browser lykkedes ikke. Prøv igen.",
|
||||
"handoff_banner": "Et program på denne computer har bedt om dette login til Bulwark-appen. Denne browser forbliver logget ud af Bulwark. Hvis du ikke har startet dette, så luk denne fane.",
|
||||
"consent_title": "Log ind i Bulwark-appen?",
|
||||
"consent_description": "Et program på denne computer, på port {port}, beder om login for {username}. Fortsæt kun, hvis du lige har trykket på \"Log ind med din browser…\" i Bulwark-appen her. Hvis ikke, prøver nogen måske at komme ind på din konto: vælg Ikke nu.",
|
||||
"consent_confirm": "Log ind i appen",
|
||||
"consent_cancel": "Ikke nu",
|
||||
"page_done": "Du kan lukke denne fane og gå tilbage til Bulwark.",
|
||||
"page_failed": "Login lykkedes ikke. Gå tilbage til Bulwark, og prøv igen.",
|
||||
"page_link_used": "Dette link virker kun én gang. Gå tilbage til Bulwark, og start forfra.",
|
||||
"page_title": "Log ind på {server}",
|
||||
"page_intro": "Denne side kommer fra Bulwark-appen på denne computer. Din adgangskode går til {server} og ingen andre steder.",
|
||||
"page_username": "E-mailadresse eller brugernavn",
|
||||
"page_password": "Adgangskode",
|
||||
"page_code": "Tofaktorkode",
|
||||
"page_submit": "Log ind",
|
||||
"page_wrong_password": "Serveren accepterede ikke dette brugernavn og denne adgangskode.",
|
||||
"page_code_needed": "Denne konto skal også bruge en tofaktorkode. Indtast din adgangskode igen og koden.",
|
||||
"page_no_answer": "Bulwark kunne ikke logge ind. Tjek, at serveren kan nås, og prøv igen.",
|
||||
"declined": "Du sagde nej i din browser, så der blev ikke logget ind.",
|
||||
"too_many": "For mange loginforsøg fra dette netværk. Vent et par minutter, og prøv igen.",
|
||||
"page_declined": "Intet blev overdraget. Du kan lukke denne fane."
|
||||
},
|
||||
"desktop_updates": {
|
||||
"title": "Opdateringer",
|
||||
"version_label": "Bulwark {version}",
|
||||
|
||||
+30
-4
@@ -6873,14 +6873,40 @@
|
||||
"blocked_minutes": "Zu viele Versuche. Versuchen Sie es in {count, plural, one {# Minute} other {# Minuten}} noch einmal."
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"desktop_browser_sign_in": {
|
||||
"button": "Mit Ihrem Browser anmelden…",
|
||||
"waiting": "Warten auf Ihren Browser…",
|
||||
"waiting_hint": "Schließen Sie die Anmeldung dort ab. Ihr Passwort-Manager funktioniert in Ihrem Browser.",
|
||||
"cancel": "Abbrechen",
|
||||
"or_here": "oder hier anmelden",
|
||||
"timed_out": "Die Anmeldung im Browser hat zu lange gedauert. Versuchen Sie es noch einmal.",
|
||||
"no_browser": "Bulwark konnte Ihren Browser nicht öffnen. Versuchen Sie es noch einmal oder melden Sie sich hier an.",
|
||||
"failed": "Die Anmeldung über Ihren Browser hat nicht geklappt. Versuchen Sie es noch einmal.",
|
||||
"handoff_banner": "Ein Programm auf diesem Computer hat diese Anmeldung für die Bulwark-App angefordert. Dieser Browser bleibt bei Bulwark abgemeldet. Wenn Sie das nicht gestartet haben, schließen Sie diesen Tab.",
|
||||
"consent_title": "Bei der Bulwark-App anmelden?",
|
||||
"consent_description": "Ein Programm auf diesem Computer, an Port {port}, fragt nach der Anmeldung für {username}. Fahren Sie nur fort, wenn Sie gerade in der Bulwark-App hier auf „Mit Ihrem Browser anmelden…“ geklickt haben. Wenn nicht, versucht vielleicht jemand, in Ihr Konto zu gelangen: Wählen Sie „Jetzt nicht“.",
|
||||
"consent_confirm": "Bei der App anmelden",
|
||||
"consent_cancel": "Jetzt nicht",
|
||||
"page_done": "Sie können diesen Tab schließen und zu Bulwark zurückkehren.",
|
||||
"page_failed": "Die Anmeldung hat nicht geklappt. Gehen Sie zurück zu Bulwark und versuchen Sie es noch einmal.",
|
||||
"page_link_used": "Dieser Link funktioniert nur einmal. Gehen Sie zurück zu Bulwark und beginnen Sie neu.",
|
||||
"page_title": "Bei {server} anmelden",
|
||||
"page_intro": "Diese Seite kommt von der Bulwark-App auf diesem Computer. Ihr Passwort geht an {server} und sonst nirgendwohin.",
|
||||
"page_username": "E-Mail-Adresse oder Benutzername",
|
||||
"page_password": "Passwort",
|
||||
"page_code": "Zwei-Faktor-Code",
|
||||
"page_submit": "Anmelden",
|
||||
"page_wrong_password": "Der Server hat diesen Benutzernamen und dieses Passwort nicht akzeptiert.",
|
||||
"page_code_needed": "Dieses Konto braucht außerdem einen Zwei-Faktor-Code. Geben Sie Ihr Passwort noch einmal und den Code ein.",
|
||||
"page_no_answer": "Bulwark konnte sich nicht anmelden. Prüfen Sie, ob der Server erreichbar ist, und versuchen Sie es noch einmal.",
|
||||
"declined": "Sie haben im Browser abgelehnt, deshalb wurde nichts angemeldet.",
|
||||
"too_many": "Zu viele Anmeldeversuche aus diesem Netzwerk. Warten Sie ein paar Minuten und versuchen Sie es noch einmal.",
|
||||
"page_declined": "Es wurde nichts übergeben. Sie können diesen Tab schließen."
|
||||
},
|
||||
"desktop_updates": {
|
||||
"title": "Updates",
|
||||
"version_label": "Bulwark {version}",
|
||||
|
||||
+30
-4
@@ -6873,14 +6873,40 @@
|
||||
"blocked_minutes": "Too many tries. Try again in {count, plural, one {# minute} other {# minutes}}."
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"desktop_browser_sign_in": {
|
||||
"button": "Sign in with your browser…",
|
||||
"waiting": "Waiting for your browser…",
|
||||
"waiting_hint": "Finish signing in there. Your password manager works in your browser.",
|
||||
"cancel": "Cancel",
|
||||
"or_here": "or sign in here",
|
||||
"timed_out": "The browser sign-in took too long. Try again.",
|
||||
"no_browser": "Bulwark could not open your browser. Try again, or sign in here.",
|
||||
"failed": "Signing in through your browser did not work. Try again.",
|
||||
"handoff_banner": "A program on this computer asked for this sign-in, for the Bulwark app. This browser stays signed out of Bulwark. If you didn't start this, close this tab.",
|
||||
"consent_title": "Sign in to the Bulwark app?",
|
||||
"consent_description": "A program on this computer, at port {port}, is asking for the sign-in for {username}. Continue only if you just pressed \"Sign in with your browser…\" in the Bulwark app here. If you didn't, someone may be trying to get into your account: choose Not now.",
|
||||
"consent_confirm": "Sign in to the app",
|
||||
"consent_cancel": "Not now",
|
||||
"page_done": "You can close this tab and go back to Bulwark.",
|
||||
"page_failed": "Signing in did not work. Go back to Bulwark and try again.",
|
||||
"page_link_used": "This link only works once. Go back to Bulwark and start again.",
|
||||
"page_title": "Sign in to {server}",
|
||||
"page_intro": "This page comes from the Bulwark app on this computer. Your password goes to {server} and nowhere else.",
|
||||
"page_username": "Email address or username",
|
||||
"page_password": "Password",
|
||||
"page_code": "Two-factor code",
|
||||
"page_submit": "Sign in",
|
||||
"page_wrong_password": "The server did not accept this username and password.",
|
||||
"page_code_needed": "This account also needs a two-factor code. Enter your password again and the code.",
|
||||
"page_no_answer": "Bulwark could not sign in. Check that the server is reachable and try again.",
|
||||
"declined": "You said no in your browser, so nothing was signed in.",
|
||||
"too_many": "Too many sign-in attempts from this network. Wait a few minutes and try again.",
|
||||
"page_declined": "Nothing was handed over. You can close this tab."
|
||||
},
|
||||
"desktop_updates": {
|
||||
"title": "Updates",
|
||||
"version_label": "Bulwark {version}",
|
||||
|
||||
+30
-4
@@ -6873,14 +6873,40 @@
|
||||
"blocked_minutes": "Demasiados intentos. Inténtelo de nuevo en {count, plural, one {# minuto} other {# minutos}}."
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"desktop_browser_sign_in": {
|
||||
"button": "Iniciar sesión con tu navegador…",
|
||||
"waiting": "Esperando a tu navegador…",
|
||||
"waiting_hint": "Termina de iniciar sesión allí. Tu gestor de contraseñas funciona en tu navegador.",
|
||||
"cancel": "Cancelar",
|
||||
"or_here": "o inicia sesión aquí",
|
||||
"timed_out": "El inicio de sesión en el navegador tardó demasiado. Inténtalo de nuevo.",
|
||||
"no_browser": "Bulwark no pudo abrir tu navegador. Inténtalo de nuevo o inicia sesión aquí.",
|
||||
"failed": "No se pudo iniciar sesión a través de tu navegador. Inténtalo de nuevo.",
|
||||
"handoff_banner": "Un programa de este ordenador ha pedido este inicio de sesión para la aplicación Bulwark. Este navegador sigue sin sesión en Bulwark. Si no lo has iniciado tú, cierra esta pestaña.",
|
||||
"consent_title": "¿Iniciar sesión en la aplicación Bulwark?",
|
||||
"consent_description": "Un programa de este ordenador, en el puerto {port}, pide el inicio de sesión de {username}. Continúa solo si acabas de pulsar «Iniciar sesión con tu navegador…» en la aplicación Bulwark de aquí. Si no, puede que alguien intente entrar en tu cuenta: elige Ahora no.",
|
||||
"consent_confirm": "Iniciar sesión en la aplicación",
|
||||
"consent_cancel": "Ahora no",
|
||||
"page_done": "Puedes cerrar esta pestaña y volver a Bulwark.",
|
||||
"page_failed": "No se pudo iniciar sesión. Vuelve a Bulwark e inténtalo de nuevo.",
|
||||
"page_link_used": "Este enlace solo funciona una vez. Vuelve a Bulwark y empieza de nuevo.",
|
||||
"page_title": "Iniciar sesión en {server}",
|
||||
"page_intro": "Esta página viene de la aplicación Bulwark de este ordenador. Tu contraseña va a {server} y a ningún otro sitio.",
|
||||
"page_username": "Correo electrónico o nombre de usuario",
|
||||
"page_password": "Contraseña",
|
||||
"page_code": "Código de dos factores",
|
||||
"page_submit": "Iniciar sesión",
|
||||
"page_wrong_password": "El servidor no aceptó este nombre de usuario y esta contraseña.",
|
||||
"page_code_needed": "Esta cuenta también necesita un código de dos factores. Escribe de nuevo tu contraseña y el código.",
|
||||
"page_no_answer": "Bulwark no pudo iniciar sesión. Comprueba que el servidor esté accesible e inténtalo de nuevo.",
|
||||
"declined": "Dijiste que no en tu navegador, así que no se inició ninguna sesión.",
|
||||
"too_many": "Demasiados intentos de inicio de sesión desde esta red. Espera unos minutos e inténtalo de nuevo.",
|
||||
"page_declined": "No se ha entregado nada. Puedes cerrar esta pestaña."
|
||||
},
|
||||
"desktop_updates": {
|
||||
"title": "Actualizaciones",
|
||||
"version_label": "Bulwark {version}",
|
||||
|
||||
+30
-4
@@ -6873,14 +6873,40 @@
|
||||
"blocked_minutes": "Liiga palju katseid. Proovige uuesti {count, plural, one {# minuti} other {# minuti}} pärast."
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"desktop_browser_sign_in": {
|
||||
"button": "Logi sisse brauseriga…",
|
||||
"waiting": "Ootan brauserit…",
|
||||
"waiting_hint": "Lõpeta sisselogimine seal. Sinu paroolihaldur töötab brauseris.",
|
||||
"cancel": "Tühista",
|
||||
"or_here": "või logi sisse siin",
|
||||
"timed_out": "Sisselogimine brauseris võttis liiga kaua aega. Proovi uuesti.",
|
||||
"no_browser": "Bulwark ei saanud brauserit avada. Proovi uuesti või logi sisse siin.",
|
||||
"failed": "Sisselogimine brauseri kaudu ei õnnestunud. Proovi uuesti.",
|
||||
"handoff_banner": "Selle arvuti programm küsis seda sisselogimist Bulwarki rakendusele. See brauser jääb Bulwarkist välja logituks. Kui sina seda ei alustanud, sulge see vaheleht.",
|
||||
"consent_title": "Kas logida sisse Bulwarki rakendusse?",
|
||||
"consent_description": "Selle arvuti programm pordil {port} küsib konto {username} sisselogimist. Jätka ainult siis, kui vajutasid just selle arvuti Bulwarki rakenduses nuppu „Logi sisse brauseriga…“. Kui mitte, võib keegi proovida sinu kontole pääseda: vali Mitte praegu.",
|
||||
"consent_confirm": "Logi rakendusse sisse",
|
||||
"consent_cancel": "Mitte praegu",
|
||||
"page_done": "Võid selle vahelehe sulgeda ja Bulwarki naasta.",
|
||||
"page_failed": "Sisselogimine ei õnnestunud. Mine tagasi Bulwarki ja proovi uuesti.",
|
||||
"page_link_used": "See link töötab ainult üks kord. Mine tagasi Bulwarki ja alusta uuesti.",
|
||||
"page_title": "Logi sisse serverisse {server}",
|
||||
"page_intro": "See leht tuleb selle arvuti Bulwarki rakendusest. Sinu parool läheb serverisse {server} ja mitte kuhugi mujale.",
|
||||
"page_username": "E-posti aadress või kasutajanimi",
|
||||
"page_password": "Parool",
|
||||
"page_code": "Kaheastmelise kinnituse kood",
|
||||
"page_submit": "Logi sisse",
|
||||
"page_wrong_password": "Server ei aktsepteerinud seda kasutajanime ja parooli.",
|
||||
"page_code_needed": "See konto vajab ka kaheastmelise kinnituse koodi. Sisesta parool uuesti ja kood.",
|
||||
"page_no_answer": "Bulwark ei saanud sisse logida. Kontrolli, kas server on kättesaadav, ja proovi uuesti.",
|
||||
"declined": "Ütlesid brauseris ei, seega sisse ei logitud.",
|
||||
"too_many": "Sellest võrgust on liiga palju sisselogimiskatseid. Oota paar minutit ja proovi uuesti.",
|
||||
"page_declined": "Midagi ei antud üle. Võid selle vahelehe sulgeda."
|
||||
},
|
||||
"desktop_updates": {
|
||||
"title": "Uuendused",
|
||||
"version_label": "Bulwark {version}",
|
||||
|
||||
+30
-4
@@ -6873,14 +6873,40 @@
|
||||
"blocked_minutes": "تلاشهای زیاد. {count, plural, one {# دقیقه} other {# دقیقه}} دیگر دوباره تلاش کنید."
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "ورود با مرورگر",
|
||||
"browser_waiting": "ورود را در مرورگر کامل کنید",
|
||||
"browser_unavailable": "این سرور ورود از طریق مرورگر را ارائه نمیدهد. از گذرواژه خود استفاده کنید.",
|
||||
"browser_failed": "ورود از طریق مرورگر انجام نشد. دوباره تلاش کنید.",
|
||||
"server_needed": "ابتدا نشانی سرور را وارد کنید.",
|
||||
"server_declined": "شما این سرور را مجاز نکردید، پس چیزی به آن فرستاده نشد.",
|
||||
"server_invalid": "این نشانی سرور معتبر نیست."
|
||||
},
|
||||
"desktop_browser_sign_in": {
|
||||
"button": "ورود با مرورگر…",
|
||||
"waiting": "در انتظار مرورگر…",
|
||||
"waiting_hint": "ورود را همانجا کامل کنید. مدیر گذرواژه شما در مرورگر کار میکند.",
|
||||
"cancel": "لغو",
|
||||
"or_here": "یا اینجا وارد شوید",
|
||||
"timed_out": "ورود در مرورگر بیش از حد طول کشید. دوباره تلاش کنید.",
|
||||
"no_browser": "Bulwark نتوانست مرورگر را باز کند. دوباره تلاش کنید یا اینجا وارد شوید.",
|
||||
"failed": "ورود از طریق مرورگر انجام نشد. دوباره تلاش کنید.",
|
||||
"handoff_banner": "برنامهای روی این رایانه این ورود را برای برنامه Bulwark درخواست کرده است. این مرورگر همچنان از Bulwark خارج میماند. اگر شما این کار را شروع نکردهاید، این زبانه را ببندید.",
|
||||
"consent_title": "به برنامه Bulwark وارد میشوید؟",
|
||||
"consent_description": "برنامهای روی این رایانه، در درگاه {port}، ورود {username} را درخواست میکند. فقط اگر همین الان «ورود با مرورگر…» را در برنامه Bulwark اینجا زدهاید ادامه دهید. در غیر این صورت شاید کسی بخواهد وارد حساب شما شود: «اکنون نه» را انتخاب کنید.",
|
||||
"consent_confirm": "ورود به برنامه",
|
||||
"consent_cancel": "اکنون نه",
|
||||
"page_done": "میتوانید این زبانه را ببندید و به Bulwark برگردید.",
|
||||
"page_failed": "ورود انجام نشد. به Bulwark برگردید و دوباره تلاش کنید.",
|
||||
"page_link_used": "این پیوند فقط یک بار کار میکند. به Bulwark برگردید و از نو شروع کنید.",
|
||||
"page_title": "ورود به {server}",
|
||||
"page_intro": "این صفحه از برنامه Bulwark روی این رایانه میآید. گذرواژه شما فقط به {server} میرود.",
|
||||
"page_username": "نشانی ایمیل یا نام کاربری",
|
||||
"page_password": "گذرواژه",
|
||||
"page_code": "کد دومرحلهای",
|
||||
"page_submit": "ورود",
|
||||
"page_wrong_password": "سرور این نام کاربری و گذرواژه را نپذیرفت.",
|
||||
"page_code_needed": "این حساب به کد دومرحلهای هم نیاز دارد. گذرواژه و کد را دوباره وارد کنید.",
|
||||
"page_no_answer": "Bulwark نتوانست وارد شود. بررسی کنید که سرور در دسترس باشد و دوباره تلاش کنید.",
|
||||
"declined": "در مرورگر رد کردید، پس ورودی انجام نشد.",
|
||||
"too_many": "تلاشهای ورود از این شبکه بیش از حد است. چند دقیقه صبر کنید و دوباره تلاش کنید.",
|
||||
"page_declined": "چیزی تحویل داده نشد. میتوانید این زبانه را ببندید."
|
||||
},
|
||||
"desktop_updates": {
|
||||
"title": "بهروزرسانیها",
|
||||
"version_label": "Bulwark {version}",
|
||||
|
||||
+30
-4
@@ -6873,14 +6873,40 @@
|
||||
"blocked_minutes": "Trop d'essais. Réessayez dans {count, plural, one {# minute} other {# minutes}}."
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"desktop_browser_sign_in": {
|
||||
"button": "Se connecter avec votre navigateur…",
|
||||
"waiting": "En attente de votre navigateur…",
|
||||
"waiting_hint": "Terminez la connexion là-bas. Votre gestionnaire de mots de passe fonctionne dans votre navigateur.",
|
||||
"cancel": "Annuler",
|
||||
"or_here": "ou se connecter ici",
|
||||
"timed_out": "La connexion dans le navigateur a pris trop de temps. Réessayez.",
|
||||
"no_browser": "Bulwark n’a pas pu ouvrir votre navigateur. Réessayez ou connectez-vous ici.",
|
||||
"failed": "La connexion via votre navigateur n’a pas fonctionné. Réessayez.",
|
||||
"handoff_banner": "Un programme sur cet ordinateur a demandé cette connexion pour l’application Bulwark. Ce navigateur reste déconnecté de Bulwark. Si vous n’êtes pas à l’origine de cela, fermez cet onglet.",
|
||||
"consent_title": "Se connecter à l’application Bulwark ?",
|
||||
"consent_description": "Un programme sur cet ordinateur, au port {port}, demande la connexion de {username}. Continuez seulement si vous venez de cliquer sur « Se connecter avec votre navigateur… » dans l’application Bulwark ici. Sinon, quelqu’un essaie peut-être d’accéder à votre compte : choisissez Pas maintenant.",
|
||||
"consent_confirm": "Se connecter à l’application",
|
||||
"consent_cancel": "Pas maintenant",
|
||||
"page_done": "Vous pouvez fermer cet onglet et revenir à Bulwark.",
|
||||
"page_failed": "La connexion n’a pas fonctionné. Revenez à Bulwark et réessayez.",
|
||||
"page_link_used": "Ce lien ne fonctionne qu’une fois. Revenez à Bulwark et recommencez.",
|
||||
"page_title": "Se connecter à {server}",
|
||||
"page_intro": "Cette page vient de l’application Bulwark sur cet ordinateur. Votre mot de passe va à {server} et nulle part ailleurs.",
|
||||
"page_username": "Adresse e-mail ou nom d’utilisateur",
|
||||
"page_password": "Mot de passe",
|
||||
"page_code": "Code à deux facteurs",
|
||||
"page_submit": "Se connecter",
|
||||
"page_wrong_password": "Le serveur n’a pas accepté ce nom d’utilisateur et ce mot de passe.",
|
||||
"page_code_needed": "Ce compte demande aussi un code à deux facteurs. Saisissez à nouveau votre mot de passe et le code.",
|
||||
"page_no_answer": "Bulwark n’a pas pu se connecter. Vérifiez que le serveur est joignable et réessayez.",
|
||||
"declined": "Vous avez refusé dans votre navigateur : aucune connexion n’a été faite.",
|
||||
"too_many": "Trop de tentatives de connexion depuis ce réseau. Attendez quelques minutes et réessayez.",
|
||||
"page_declined": "Rien n’a été transmis. Vous pouvez fermer cet onglet."
|
||||
},
|
||||
"desktop_updates": {
|
||||
"title": "Mises à jour",
|
||||
"version_label": "Bulwark {version}",
|
||||
|
||||
+30
-4
@@ -6874,14 +6874,40 @@
|
||||
"blocked_minutes": "יותר מדי ניסיונות. נסו שוב בעוד {count, plural, one {דקה אחת} two {שתי דקות} other {# דקות}}."
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "כניסה באמצעות הדפדפן",
|
||||
"browser_waiting": "יש להשלים את הכניסה בדפדפן",
|
||||
"browser_unavailable": "השרת הזה לא מאפשר כניסה דרך דפדפן. יש להשתמש בסיסמה.",
|
||||
"browser_failed": "הכניסה דרך הדפדפן לא הצליחה. כדאי לנסות שוב.",
|
||||
"server_needed": "קודם צריך להזין את כתובת השרת.",
|
||||
"server_declined": "לא אישרת את השרת הזה, לכן לא נשלח אליו דבר.",
|
||||
"server_invalid": "כתובת השרת הזו לא תקינה."
|
||||
},
|
||||
"desktop_browser_sign_in": {
|
||||
"button": "התחברות דרך הדפדפן…",
|
||||
"waiting": "ממתין לדפדפן…",
|
||||
"waiting_hint": "סיימו את ההתחברות שם. מנהל הסיסמאות שלכם פועל בדפדפן.",
|
||||
"cancel": "ביטול",
|
||||
"or_here": "או התחברו כאן",
|
||||
"timed_out": "ההתחברות בדפדפן נמשכה זמן רב מדי. נסו שוב.",
|
||||
"no_browser": "Bulwark לא הצליח לפתוח את הדפדפן. נסו שוב או התחברו כאן.",
|
||||
"failed": "ההתחברות דרך הדפדפן לא הצליחה. נסו שוב.",
|
||||
"handoff_banner": "תוכנה במחשב זה ביקשה את ההתחברות הזו עבור אפליקציית Bulwark. הדפדפן הזה נשאר מנותק מ-Bulwark. אם לא אתם התחלתם את זה, סגרו את הכרטיסייה.",
|
||||
"consent_title": "להתחבר לאפליקציית Bulwark?",
|
||||
"consent_description": "תוכנה במחשב זה, בפורט {port}, מבקשת את ההתחברות של {username}. המשיכו רק אם לחצתם עכשיו על \"התחברות דרך הדפדפן…\" באפליקציית Bulwark כאן. אם לא, ייתכן שמישהו מנסה להיכנס לחשבון שלכם: בחרו \"לא עכשיו\".",
|
||||
"consent_confirm": "התחברות לאפליקציה",
|
||||
"consent_cancel": "לא עכשיו",
|
||||
"page_done": "אפשר לסגור את הכרטיסייה הזו ולחזור ל-Bulwark.",
|
||||
"page_failed": "ההתחברות לא הצליחה. חזרו ל-Bulwark ונסו שוב.",
|
||||
"page_link_used": "הקישור הזה עובד פעם אחת בלבד. חזרו ל-Bulwark והתחילו מחדש.",
|
||||
"page_title": "התחברות אל {server}",
|
||||
"page_intro": "הדף הזה מגיע מאפליקציית Bulwark במחשב זה. הסיסמה שלכם נשלחת אל {server} בלבד.",
|
||||
"page_username": "כתובת דוא״ל או שם משתמש",
|
||||
"page_password": "סיסמה",
|
||||
"page_code": "קוד אימות דו-שלבי",
|
||||
"page_submit": "התחברות",
|
||||
"page_wrong_password": "השרת לא קיבל את שם המשתמש והסיסמה האלה.",
|
||||
"page_code_needed": "החשבון הזה צריך גם קוד אימות דו-שלבי. הזינו שוב את הסיסמה ואת הקוד.",
|
||||
"page_no_answer": "Bulwark לא הצליח להתחבר. בדקו שהשרת זמין ונסו שוב.",
|
||||
"declined": "סירבתם בדפדפן, ולכן לא בוצעה התחברות.",
|
||||
"too_many": "יותר מדי ניסיונות התחברות מהרשת הזו. המתינו כמה דקות ונסו שוב.",
|
||||
"page_declined": "שום דבר לא הועבר. אפשר לסגור את הכרטיסייה."
|
||||
},
|
||||
"desktop_updates": {
|
||||
"title": "עדכונים",
|
||||
"version_label": "Bulwark {version}",
|
||||
|
||||
+30
-4
@@ -6873,14 +6873,40 @@
|
||||
"blocked_minutes": "Túl sok próbálkozás. Próbálja újra {count, plural, one {# perc} other {# perc}} múlva."
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"desktop_browser_sign_in": {
|
||||
"button": "Bejelentkezés a böngészővel…",
|
||||
"waiting": "Várakozás a böngészőre…",
|
||||
"waiting_hint": "Fejezze be ott a bejelentkezést. A jelszókezelője a böngészőben működik.",
|
||||
"cancel": "Mégse",
|
||||
"or_here": "vagy jelentkezzen be itt",
|
||||
"timed_out": "A böngészős bejelentkezés túl sokáig tartott. Próbálja újra.",
|
||||
"no_browser": "A Bulwark nem tudta megnyitni a böngészőt. Próbálja újra, vagy jelentkezzen be itt.",
|
||||
"failed": "A böngészőn keresztüli bejelentkezés nem sikerült. Próbálja újra.",
|
||||
"handoff_banner": "A számítógépen egy program kérte ezt a bejelentkezést a Bulwark alkalmazáshoz. Ez a böngésző kijelentkezve marad a Bulwarkból. Ha nem Ön indította, zárja be ezt a lapot.",
|
||||
"consent_title": "Bejelentkezik a Bulwark alkalmazásba?",
|
||||
"consent_description": "A számítógépen egy program a(z) {port} porton a(z) {username} bejelentkezését kéri. Csak akkor folytassa, ha az imént nyomta meg a „Bejelentkezés a böngészővel…” gombot az itteni Bulwark alkalmazásban. Ha nem, lehet, hogy valaki be akar jutni a fiókjába: válassza a Most nem lehetőséget.",
|
||||
"consent_confirm": "Bejelentkezés az alkalmazásba",
|
||||
"consent_cancel": "Most nem",
|
||||
"page_done": "Bezárhatja ezt a lapot, és visszatérhet a Bulwarkhoz.",
|
||||
"page_failed": "A bejelentkezés nem sikerült. Térjen vissza a Bulwarkhoz, és próbálja újra.",
|
||||
"page_link_used": "Ez a hivatkozás csak egyszer működik. Térjen vissza a Bulwarkhoz, és kezdje újra.",
|
||||
"page_title": "Bejelentkezés ide: {server}",
|
||||
"page_intro": "Ez az oldal a számítógépen lévő Bulwark alkalmazástól származik. A jelszava a(z) {server} szerverre kerül, és sehova máshova.",
|
||||
"page_username": "E-mail-cím vagy felhasználónév",
|
||||
"page_password": "Jelszó",
|
||||
"page_code": "Kétfaktoros kód",
|
||||
"page_submit": "Bejelentkezés",
|
||||
"page_wrong_password": "A szerver nem fogadta el ezt a felhasználónevet és jelszót.",
|
||||
"page_code_needed": "Ehhez a fiókhoz kétfaktoros kód is kell. Adja meg újra a jelszavát és a kódot.",
|
||||
"page_no_answer": "A Bulwark nem tudott bejelentkezni. Ellenőrizze, hogy a szerver elérhető-e, és próbálja újra.",
|
||||
"declined": "A böngészőben nemet mondott, így nem történt bejelentkezés.",
|
||||
"too_many": "Túl sok bejelentkezési kísérlet erről a hálózatról. Várjon néhány percet, és próbálja újra.",
|
||||
"page_declined": "Semmi nem került átadásra. Bezárhatja ezt a lapot."
|
||||
},
|
||||
"desktop_updates": {
|
||||
"title": "Frissítések",
|
||||
"version_label": "Bulwark {version}",
|
||||
|
||||
+30
-4
@@ -6873,14 +6873,40 @@
|
||||
"blocked_minutes": "Troppi tentativi. Riprova tra {count, plural, one {# minuto} other {# minuti}}."
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"desktop_browser_sign_in": {
|
||||
"button": "Accedi con il browser…",
|
||||
"waiting": "In attesa del browser…",
|
||||
"waiting_hint": "Completa l’accesso lì. Il tuo gestore di password funziona nel browser.",
|
||||
"cancel": "Annulla",
|
||||
"or_here": "oppure accedi qui",
|
||||
"timed_out": "L’accesso nel browser ha richiesto troppo tempo. Riprova.",
|
||||
"no_browser": "Bulwark non è riuscito ad aprire il browser. Riprova o accedi qui.",
|
||||
"failed": "L’accesso tramite il browser non è riuscito. Riprova.",
|
||||
"handoff_banner": "Un programma su questo computer ha chiesto questo accesso per l’app Bulwark. Questo browser resta disconnesso da Bulwark. Se non sei stato tu ad avviarlo, chiudi questa scheda.",
|
||||
"consent_title": "Accedere all’app Bulwark?",
|
||||
"consent_description": "Un programma su questo computer, alla porta {port}, chiede l’accesso per {username}. Continua solo se hai appena premuto «Accedi con il browser…» nell’app Bulwark qui. Altrimenti qualcuno potrebbe cercare di entrare nel tuo account: scegli Non ora.",
|
||||
"consent_confirm": "Accedi all’app",
|
||||
"consent_cancel": "Non ora",
|
||||
"page_done": "Puoi chiudere questa scheda e tornare a Bulwark.",
|
||||
"page_failed": "L’accesso non è riuscito. Torna a Bulwark e riprova.",
|
||||
"page_link_used": "Questo link funziona una sola volta. Torna a Bulwark e ricomincia.",
|
||||
"page_title": "Accedi a {server}",
|
||||
"page_intro": "Questa pagina viene dall’app Bulwark su questo computer. La tua password va a {server} e da nessun’altra parte.",
|
||||
"page_username": "Indirizzo e-mail o nome utente",
|
||||
"page_password": "Password",
|
||||
"page_code": "Codice a due fattori",
|
||||
"page_submit": "Accedi",
|
||||
"page_wrong_password": "Il server non ha accettato questo nome utente e questa password.",
|
||||
"page_code_needed": "Questo account richiede anche un codice a due fattori. Inserisci di nuovo la password e il codice.",
|
||||
"page_no_answer": "Bulwark non è riuscito ad accedere. Verifica che il server sia raggiungibile e riprova.",
|
||||
"declined": "Hai detto no nel browser, quindi non è stato effettuato alcun accesso.",
|
||||
"too_many": "Troppi tentativi di accesso da questa rete. Attendi qualche minuto e riprova.",
|
||||
"page_declined": "Non è stato consegnato nulla. Puoi chiudere questa scheda."
|
||||
},
|
||||
"desktop_updates": {
|
||||
"title": "Aggiornamenti",
|
||||
"version_label": "Bulwark {version}",
|
||||
|
||||
+30
-4
@@ -6873,14 +6873,40 @@
|
||||
"blocked_minutes": "試行回数が多すぎます。{count, plural, other {# 分}}後にもう一度お試しください。"
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "ブラウザでサインイン",
|
||||
"browser_waiting": "ブラウザでサインインを完了してください",
|
||||
"browser_unavailable": "このサーバーはブラウザでのサインインに対応していません。パスワードを使用してください。",
|
||||
"browser_failed": "ブラウザでのサインインに失敗しました。もう一度お試しください。",
|
||||
"server_needed": "先にサーバーのアドレスを入力してください。",
|
||||
"server_declined": "このサーバーを許可しなかったため、何も送信していません。",
|
||||
"server_invalid": "このサーバーアドレスは正しくありません。"
|
||||
},
|
||||
"desktop_browser_sign_in": {
|
||||
"button": "ブラウザーでサインイン…",
|
||||
"waiting": "ブラウザーを待っています…",
|
||||
"waiting_hint": "ブラウザーでサインインを完了してください。パスワードマネージャーはブラウザーで使えます。",
|
||||
"cancel": "キャンセル",
|
||||
"or_here": "またはここでサインイン",
|
||||
"timed_out": "ブラウザーでのサインインに時間がかかりすぎました。もう一度お試しください。",
|
||||
"no_browser": "Bulwark はブラウザーを開けませんでした。もう一度試すか、ここでサインインしてください。",
|
||||
"failed": "ブラウザー経由でサインインできませんでした。もう一度お試しください。",
|
||||
"handoff_banner": "このコンピューターのプログラムが Bulwark アプリのためにこのサインインを求めました。このブラウザーは Bulwark にサインインしません。自分で始めたのでなければ、このタブを閉じてください。",
|
||||
"consent_title": "Bulwark アプリにサインインしますか?",
|
||||
"consent_description": "このコンピューターのポート {port} のプログラムが、{username} のサインインを求めています。ここの Bulwark アプリで「ブラウザーでサインイン…」を押したばかりの場合のみ続行してください。そうでない場合は、誰かがアカウントに入ろうとしている可能性があります。「今はしない」を選んでください。",
|
||||
"consent_confirm": "アプリにサインイン",
|
||||
"consent_cancel": "今はしない",
|
||||
"page_done": "このタブを閉じて Bulwark に戻れます。",
|
||||
"page_failed": "サインインできませんでした。Bulwark に戻ってもう一度お試しください。",
|
||||
"page_link_used": "このリンクは 1 回しか使えません。Bulwark に戻ってやり直してください。",
|
||||
"page_title": "{server} にサインイン",
|
||||
"page_intro": "このページは、このコンピューターの Bulwark アプリが表示しています。パスワードは {server} にだけ送られます。",
|
||||
"page_username": "メールアドレスまたはユーザー名",
|
||||
"page_password": "パスワード",
|
||||
"page_code": "2 要素認証コード",
|
||||
"page_submit": "サインイン",
|
||||
"page_wrong_password": "サーバーはこのユーザー名とパスワードを受け付けませんでした。",
|
||||
"page_code_needed": "このアカウントには 2 要素認証コードも必要です。パスワードをもう一度入力し、コードも入力してください。",
|
||||
"page_no_answer": "Bulwark はサインインできませんでした。サーバーに接続できるか確認して、もう一度お試しください。",
|
||||
"declined": "ブラウザーで断ったため、サインインしていません。",
|
||||
"too_many": "このネットワークからのサインインの試行が多すぎます。数分待ってからもう一度お試しください。",
|
||||
"page_declined": "何も渡していません。このタブを閉じてかまいません。"
|
||||
},
|
||||
"desktop_updates": {
|
||||
"title": "アップデート",
|
||||
"version_label": "Bulwark {version}",
|
||||
|
||||
+30
-4
@@ -6873,14 +6873,40 @@
|
||||
"blocked_minutes": "시도 횟수가 너무 많습니다. {count, plural, other {#분}} 후에 다시 시도하세요."
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "브라우저로 로그인",
|
||||
"browser_waiting": "브라우저에서 로그인을 마치세요",
|
||||
"browser_unavailable": "이 서버는 브라우저 로그인을 지원하지 않습니다. 비밀번호를 사용하세요.",
|
||||
"browser_failed": "브라우저 로그인에 실패했습니다. 다시 시도하세요.",
|
||||
"server_needed": "먼저 서버 주소를 입력하세요.",
|
||||
"server_declined": "이 서버를 허용하지 않아 아무것도 보내지 않았습니다.",
|
||||
"server_invalid": "이 서버 주소는 올바르지 않습니다."
|
||||
},
|
||||
"desktop_browser_sign_in": {
|
||||
"button": "브라우저로 로그인…",
|
||||
"waiting": "브라우저를 기다리는 중…",
|
||||
"waiting_hint": "브라우저에서 로그인을 마치세요. 비밀번호 관리자는 브라우저에서 사용할 수 있습니다.",
|
||||
"cancel": "취소",
|
||||
"or_here": "또는 여기에서 로그인",
|
||||
"timed_out": "브라우저 로그인이 너무 오래 걸렸습니다. 다시 시도하세요.",
|
||||
"no_browser": "Bulwark에서 브라우저를 열 수 없습니다. 다시 시도하거나 여기에서 로그인하세요.",
|
||||
"failed": "브라우저를 통한 로그인에 실패했습니다. 다시 시도하세요.",
|
||||
"handoff_banner": "이 컴퓨터의 프로그램이 Bulwark 앱을 위해 이 로그인을 요청했습니다. 이 브라우저는 Bulwark에 로그인되지 않습니다. 직접 시작하지 않았다면 이 탭을 닫으세요.",
|
||||
"consent_title": "Bulwark 앱에 로그인할까요?",
|
||||
"consent_description": "이 컴퓨터의 포트 {port}에 있는 프로그램이 {username}의 로그인을 요청하고 있습니다. 이 컴퓨터의 Bulwark 앱에서 방금 \"브라우저로 로그인…\"을 눌렀을 때만 계속하세요. 그렇지 않다면 누군가 계정에 들어가려는 것일 수 있습니다. \"나중에\"를 선택하세요.",
|
||||
"consent_confirm": "앱에 로그인",
|
||||
"consent_cancel": "나중에",
|
||||
"page_done": "이 탭을 닫고 Bulwark로 돌아가도 됩니다.",
|
||||
"page_failed": "로그인에 실패했습니다. Bulwark로 돌아가서 다시 시도하세요.",
|
||||
"page_link_used": "이 링크는 한 번만 작동합니다. Bulwark로 돌아가서 다시 시작하세요.",
|
||||
"page_title": "{server}에 로그인",
|
||||
"page_intro": "이 페이지는 이 컴퓨터의 Bulwark 앱에서 제공합니다. 비밀번호는 {server}로만 전송됩니다.",
|
||||
"page_username": "이메일 주소 또는 사용자 이름",
|
||||
"page_password": "비밀번호",
|
||||
"page_code": "2단계 인증 코드",
|
||||
"page_submit": "로그인",
|
||||
"page_wrong_password": "서버가 이 사용자 이름과 비밀번호를 받아들이지 않았습니다.",
|
||||
"page_code_needed": "이 계정에는 2단계 인증 코드도 필요합니다. 비밀번호와 코드를 다시 입력하세요.",
|
||||
"page_no_answer": "Bulwark에서 로그인할 수 없습니다. 서버에 연결할 수 있는지 확인하고 다시 시도하세요.",
|
||||
"declined": "브라우저에서 거절하여 로그인하지 않았습니다.",
|
||||
"too_many": "이 네트워크에서 로그인 시도가 너무 많습니다. 몇 분 기다린 후 다시 시도하세요.",
|
||||
"page_declined": "아무것도 전달되지 않았습니다. 이 탭을 닫아도 됩니다."
|
||||
},
|
||||
"desktop_updates": {
|
||||
"title": "업데이트",
|
||||
"version_label": "Bulwark {version}",
|
||||
|
||||
+30
-4
@@ -6873,14 +6873,40 @@
|
||||
"blocked_minutes": "Pārāk daudz mēģinājumu. Mēģiniet vēlreiz pēc {count, plural, zero {# minūtēm} one {# minūtes} other {# minūtēm}}."
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"desktop_browser_sign_in": {
|
||||
"button": "Pieteikties ar pārlūku…",
|
||||
"waiting": "Gaida pārlūku…",
|
||||
"waiting_hint": "Pabeidziet pieteikšanos tur. Jūsu paroļu pārvaldnieks darbojas pārlūkā.",
|
||||
"cancel": "Atcelt",
|
||||
"or_here": "vai piesakieties šeit",
|
||||
"timed_out": "Pieteikšanās pārlūkā aizņēma pārāk ilgu laiku. Mēģiniet vēlreiz.",
|
||||
"no_browser": "Bulwark nevarēja atvērt pārlūku. Mēģiniet vēlreiz vai piesakieties šeit.",
|
||||
"failed": "Pieteikšanās caur pārlūku neizdevās. Mēģiniet vēlreiz.",
|
||||
"handoff_banner": "Programma šajā datorā pieprasīja šo pieteikšanos Bulwark lietotnei. Šis pārlūks paliek izrakstīts no Bulwark. Ja to nesākāt jūs, aizveriet šo cilni.",
|
||||
"consent_title": "Pieteikties Bulwark lietotnē?",
|
||||
"consent_description": "Programma šajā datorā, portā {port}, prasa pieteikšanos kontam {username}. Turpiniet tikai tad, ja tikko nospiedāt “Pieteikties ar pārlūku…” Bulwark lietotnē šajā datorā. Ja nē, iespējams, kāds mēģina piekļūt jūsu kontam: izvēlieties Ne tagad.",
|
||||
"consent_confirm": "Pieteikties lietotnē",
|
||||
"consent_cancel": "Ne tagad",
|
||||
"page_done": "Varat aizvērt šo cilni un atgriezties Bulwark.",
|
||||
"page_failed": "Pieteikšanās neizdevās. Atgriezieties Bulwark un mēģiniet vēlreiz.",
|
||||
"page_link_used": "Šī saite darbojas tikai vienreiz. Atgriezieties Bulwark un sāciet no jauna.",
|
||||
"page_title": "Pieteikties serverī {server}",
|
||||
"page_intro": "Šī lapa nāk no Bulwark lietotnes šajā datorā. Jūsu parole nonāk serverī {server} un nekur citur.",
|
||||
"page_username": "E-pasta adrese vai lietotājvārds",
|
||||
"page_password": "Parole",
|
||||
"page_code": "Divu faktoru kods",
|
||||
"page_submit": "Pieteikties",
|
||||
"page_wrong_password": "Serveris nepieņēma šo lietotājvārdu un paroli.",
|
||||
"page_code_needed": "Šim kontam vajag arī divu faktoru kodu. Ievadiet paroli vēlreiz un kodu.",
|
||||
"page_no_answer": "Bulwark nevarēja pieteikties. Pārbaudiet, vai serveris ir sasniedzams, un mēģiniet vēlreiz.",
|
||||
"declined": "Jūs pārlūkā atteicāt, tāpēc pieteikšanās nenotika.",
|
||||
"too_many": "Pārāk daudz pieteikšanās mēģinājumu no šī tīkla. Pagaidiet dažas minūtes un mēģiniet vēlreiz.",
|
||||
"page_declined": "Nekas netika nodots. Varat aizvērt šo cilni."
|
||||
},
|
||||
"desktop_updates": {
|
||||
"title": "Atjauninājumi",
|
||||
"version_label": "Bulwark {version}",
|
||||
|
||||
+30
-4
@@ -6873,14 +6873,40 @@
|
||||
"blocked_minutes": "Хэт олон оролдлого. {count, plural, one {# минутын} other {# минутын}} дараа дахин оролдоно уу."
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "Хөтчөөр нэвтрэх",
|
||||
"browser_waiting": "Нэвтрэлтээ хөтөч дээр дуусгана уу",
|
||||
"browser_unavailable": "Энэ сервер хөтчөөр нэвтрэх боломжгүй. Нууц үгээ ашиглана уу.",
|
||||
"browser_failed": "Хөтчөөр нэвтэрч чадсангүй. Дахин оролдоно уу.",
|
||||
"server_needed": "Эхлээд серверийн хаягаа оруулна уу.",
|
||||
"server_declined": "Та энэ серверийг зөвшөөрөөгүй тул юу ч илгээгээгүй.",
|
||||
"server_invalid": "Энэ серверийн хаяг буруу байна."
|
||||
},
|
||||
"desktop_browser_sign_in": {
|
||||
"button": "Хөтчөөрөө нэвтрэх…",
|
||||
"waiting": "Хөтчийг хүлээж байна…",
|
||||
"waiting_hint": "Тэнд нэвтрэлтээ дуусгана уу. Таны нууц үгийн менежер хөтөч дээр ажиллана.",
|
||||
"cancel": "Цуцлах",
|
||||
"or_here": "эсвэл энд нэвтэрнэ үү",
|
||||
"timed_out": "Хөтчөөр нэвтрэх хэт удаан үргэлжилсэн. Дахин оролдоно уу.",
|
||||
"no_browser": "Bulwark хөтчийг нээж чадсангүй. Дахин оролдох эсвэл энд нэвтэрнэ үү.",
|
||||
"failed": "Хөтчөөр нэвтэрч чадсангүй. Дахин оролдоно уу.",
|
||||
"handoff_banner": "Энэ компьютерийн програм Bulwark апп-д зориулж энэ нэвтрэлтийг хүссэн. Энэ хөтөч Bulwark-д нэвтрэхгүй хэвээр үлдэнэ. Та эхлүүлээгүй бол энэ табыг хаана уу.",
|
||||
"consent_title": "Bulwark апп руу нэвтрэх үү?",
|
||||
"consent_description": "Энэ компьютерийн {port} порт дээрх програм {username}-ийн нэвтрэлтийг хүсэж байна. Та дөнгөж сая энд байгаа Bulwark апп дээр \"Хөтчөөрөө нэвтрэх…\" дарсан бол л үргэлжлүүлнэ үү. Үгүй бол хэн нэгэн таны бүртгэлд орох гэж оролдож магадгүй: \"Одоо биш\"-ийг сонгоно уу.",
|
||||
"consent_confirm": "Апп руу нэвтрэх",
|
||||
"consent_cancel": "Одоо биш",
|
||||
"page_done": "Энэ табыг хааж Bulwark руу буцаж болно.",
|
||||
"page_failed": "Нэвтэрч чадсангүй. Bulwark руу буцаж дахин оролдоно уу.",
|
||||
"page_link_used": "Энэ холбоос зөвхөн нэг удаа ажиллана. Bulwark руу буцаж дахин эхэлнэ үү.",
|
||||
"page_title": "{server} руу нэвтрэх",
|
||||
"page_intro": "Энэ хуудас энэ компьютер дээрх Bulwark апп-аас ирсэн. Таны нууц үг зөвхөн {server} руу очно.",
|
||||
"page_username": "Имэйл хаяг эсвэл хэрэглэгчийн нэр",
|
||||
"page_password": "Нууц үг",
|
||||
"page_code": "Хоёр шатлалт код",
|
||||
"page_submit": "Нэвтрэх",
|
||||
"page_wrong_password": "Сервер энэ хэрэглэгчийн нэр, нууц үгийг хүлээн авсангүй.",
|
||||
"page_code_needed": "Энэ бүртгэлд хоёр шатлалт код бас хэрэгтэй. Нууц үг болон кодоо дахин оруулна уу.",
|
||||
"page_no_answer": "Bulwark нэвтэрч чадсангүй. Сервер холбогдох боломжтой эсэхийг шалгаад дахин оролдоно уу.",
|
||||
"declined": "Та хөтөч дээр татгалзсан тул нэвтрээгүй.",
|
||||
"too_many": "Энэ сүлжээнээс хэт олон удаа нэвтрэхийг оролдсон байна. Хэдэн минут хүлээгээд дахин оролдоно уу.",
|
||||
"page_declined": "Юу ч шилжүүлээгүй. Энэ табыг хааж болно."
|
||||
},
|
||||
"desktop_updates": {
|
||||
"title": "Шинэчлэлт",
|
||||
"version_label": "Bulwark {version}",
|
||||
|
||||
+30
-4
@@ -6873,14 +6873,40 @@
|
||||
"blocked_minutes": "For mange forsøk. Prøv igjen om {count, plural, one {# minutt} other {# minutter}}."
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"desktop_browser_sign_in": {
|
||||
"button": "Logg inn med nettleseren…",
|
||||
"waiting": "Venter på nettleseren…",
|
||||
"waiting_hint": "Fullfør innloggingen der. Passordbehandleren din virker i nettleseren.",
|
||||
"cancel": "Avbryt",
|
||||
"or_here": "eller logg inn her",
|
||||
"timed_out": "Innloggingen i nettleseren tok for lang tid. Prøv igjen.",
|
||||
"no_browser": "Bulwark kunne ikke åpne nettleseren. Prøv igjen, eller logg inn her.",
|
||||
"failed": "Innlogging via nettleseren virket ikke. Prøv igjen.",
|
||||
"handoff_banner": "Et program på denne datamaskinen ba om denne innloggingen for Bulwark-appen. Denne nettleseren forblir logget ut av Bulwark. Hvis du ikke startet dette, lukk denne fanen.",
|
||||
"consent_title": "Logge inn i Bulwark-appen?",
|
||||
"consent_description": "Et program på denne datamaskinen, på port {port}, ber om innloggingen for {username}. Fortsett bare hvis du nettopp trykket på «Logg inn med nettleseren…» i Bulwark-appen her. Hvis ikke, prøver kanskje noen å komme inn på kontoen din: velg Ikke nå.",
|
||||
"consent_confirm": "Logg inn i appen",
|
||||
"consent_cancel": "Ikke nå",
|
||||
"page_done": "Du kan lukke denne fanen og gå tilbake til Bulwark.",
|
||||
"page_failed": "Innloggingen virket ikke. Gå tilbake til Bulwark og prøv igjen.",
|
||||
"page_link_used": "Denne lenken virker bare én gang. Gå tilbake til Bulwark og start på nytt.",
|
||||
"page_title": "Logg inn på {server}",
|
||||
"page_intro": "Denne siden kommer fra Bulwark-appen på denne datamaskinen. Passordet ditt går til {server} og ingen andre steder.",
|
||||
"page_username": "E-postadresse eller brukernavn",
|
||||
"page_password": "Passord",
|
||||
"page_code": "Tofaktorkode",
|
||||
"page_submit": "Logg inn",
|
||||
"page_wrong_password": "Serveren godtok ikke dette brukernavnet og passordet.",
|
||||
"page_code_needed": "Denne kontoen trenger også en tofaktorkode. Skriv inn passordet på nytt og koden.",
|
||||
"page_no_answer": "Bulwark kunne ikke logge inn. Sjekk at serveren kan nås, og prøv igjen.",
|
||||
"declined": "Du sa nei i nettleseren, så ingenting ble logget inn.",
|
||||
"too_many": "For mange innloggingsforsøk fra dette nettverket. Vent noen minutter og prøv igjen.",
|
||||
"page_declined": "Ingenting ble overlevert. Du kan lukke denne fanen."
|
||||
},
|
||||
"desktop_updates": {
|
||||
"title": "Oppdateringer",
|
||||
"version_label": "Bulwark {version}",
|
||||
|
||||
+30
-4
@@ -6873,14 +6873,40 @@
|
||||
"blocked_minutes": "Te veel pogingen. Probeer het over {count, plural, one {# minuut} other {# minuten}} opnieuw."
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"desktop_browser_sign_in": {
|
||||
"button": "Inloggen met je browser…",
|
||||
"waiting": "Wachten op je browser…",
|
||||
"waiting_hint": "Rond het inloggen daar af. Je wachtwoordbeheerder werkt in je browser.",
|
||||
"cancel": "Annuleren",
|
||||
"or_here": "of log hier in",
|
||||
"timed_out": "Inloggen in de browser duurde te lang. Probeer het opnieuw.",
|
||||
"no_browser": "Bulwark kon je browser niet openen. Probeer het opnieuw of log hier in.",
|
||||
"failed": "Inloggen via je browser is niet gelukt. Probeer het opnieuw.",
|
||||
"handoff_banner": "Een programma op deze computer heeft om deze aanmelding gevraagd voor de Bulwark-app. Deze browser blijft uitgelogd bij Bulwark. Heb je dit niet gestart, sluit dan dit tabblad.",
|
||||
"consent_title": "Inloggen bij de Bulwark-app?",
|
||||
"consent_description": "Een programma op deze computer, op poort {port}, vraagt om de aanmelding van {username}. Ga alleen verder als je zojuist op \"Inloggen met je browser…\" hebt geklikt in de Bulwark-app hier. Zo niet, dan probeert iemand misschien in je account te komen: kies Niet nu.",
|
||||
"consent_confirm": "Inloggen bij de app",
|
||||
"consent_cancel": "Niet nu",
|
||||
"page_done": "Je kunt dit tabblad sluiten en teruggaan naar Bulwark.",
|
||||
"page_failed": "Inloggen is niet gelukt. Ga terug naar Bulwark en probeer het opnieuw.",
|
||||
"page_link_used": "Deze link werkt maar één keer. Ga terug naar Bulwark en begin opnieuw.",
|
||||
"page_title": "Inloggen bij {server}",
|
||||
"page_intro": "Deze pagina komt van de Bulwark-app op deze computer. Je wachtwoord gaat naar {server} en nergens anders heen.",
|
||||
"page_username": "E-mailadres of gebruikersnaam",
|
||||
"page_password": "Wachtwoord",
|
||||
"page_code": "Tweefactorcode",
|
||||
"page_submit": "Inloggen",
|
||||
"page_wrong_password": "De server heeft deze gebruikersnaam en dit wachtwoord niet geaccepteerd.",
|
||||
"page_code_needed": "Dit account heeft ook een tweefactorcode nodig. Voer je wachtwoord opnieuw in en de code.",
|
||||
"page_no_answer": "Bulwark kon niet inloggen. Controleer of de server bereikbaar is en probeer het opnieuw.",
|
||||
"declined": "Je hebt in je browser nee gezegd, dus er is niet ingelogd.",
|
||||
"too_many": "Te veel inlogpogingen vanaf dit netwerk. Wacht een paar minuten en probeer het opnieuw.",
|
||||
"page_declined": "Er is niets overgedragen. Je kunt dit tabblad sluiten."
|
||||
},
|
||||
"desktop_updates": {
|
||||
"title": "Updates",
|
||||
"version_label": "Bulwark {version}",
|
||||
|
||||
+30
-4
@@ -6873,14 +6873,40 @@
|
||||
"blocked_minutes": "Zbyt wiele prób. Spróbuj za {count, plural, one {# minutę} few {# minuty} many {# minut} other {# minuty}}."
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"desktop_browser_sign_in": {
|
||||
"button": "Zaloguj się w przeglądarce…",
|
||||
"waiting": "Czekanie na przeglądarkę…",
|
||||
"waiting_hint": "Dokończ logowanie w przeglądarce. Działa tam Twój menedżer haseł.",
|
||||
"cancel": "Anuluj",
|
||||
"or_here": "lub zaloguj się tutaj",
|
||||
"timed_out": "Logowanie w przeglądarce trwało zbyt długo. Spróbuj ponownie.",
|
||||
"no_browser": "Bulwark nie może otworzyć przeglądarki. Spróbuj ponownie lub zaloguj się tutaj.",
|
||||
"failed": "Logowanie przez przeglądarkę nie powiodło się. Spróbuj ponownie.",
|
||||
"handoff_banner": "Program na tym komputerze poprosił o to logowanie dla aplikacji Bulwark. Ta przeglądarka pozostaje wylogowana z Bulwark. Jeśli to nie Ty, zamknij tę kartę.",
|
||||
"consent_title": "Zalogować się w aplikacji Bulwark?",
|
||||
"consent_description": "Program na tym komputerze, na porcie {port}, prosi o logowanie dla {username}. Kontynuuj tylko wtedy, gdy przed chwilą kliknięto „Zaloguj się w przeglądarce…” w aplikacji Bulwark na tym komputerze. Jeśli nie, ktoś może próbować dostać się na Twoje konto: wybierz Nie teraz.",
|
||||
"consent_confirm": "Zaloguj się w aplikacji",
|
||||
"consent_cancel": "Nie teraz",
|
||||
"page_done": "Możesz zamknąć tę kartę i wrócić do Bulwark.",
|
||||
"page_failed": "Logowanie nie powiodło się. Wróć do Bulwark i spróbuj ponownie.",
|
||||
"page_link_used": "Ten link działa tylko raz. Wróć do Bulwark i zacznij od nowa.",
|
||||
"page_title": "Zaloguj się na {server}",
|
||||
"page_intro": "Ta strona pochodzi z aplikacji Bulwark na tym komputerze. Twoje hasło trafia na {server} i nigdzie indziej.",
|
||||
"page_username": "Adres e-mail lub nazwa użytkownika",
|
||||
"page_password": "Hasło",
|
||||
"page_code": "Kod dwuskładnikowy",
|
||||
"page_submit": "Zaloguj się",
|
||||
"page_wrong_password": "Serwer nie przyjął tej nazwy użytkownika i hasła.",
|
||||
"page_code_needed": "To konto wymaga też kodu dwuskładnikowego. Wpisz ponownie hasło i kod.",
|
||||
"page_no_answer": "Bulwark nie może się zalogować. Sprawdź, czy serwer jest dostępny, i spróbuj ponownie.",
|
||||
"declined": "Odmówiono w przeglądarce, więc nic nie zostało zalogowane.",
|
||||
"too_many": "Zbyt wiele prób logowania z tej sieci. Poczekaj kilka minut i spróbuj ponownie.",
|
||||
"page_declined": "Nic nie zostało przekazane. Możesz zamknąć tę kartę."
|
||||
},
|
||||
"desktop_updates": {
|
||||
"title": "Aktualizacje",
|
||||
"version_label": "Bulwark {version}",
|
||||
|
||||
+30
-4
@@ -6873,14 +6873,40 @@
|
||||
"blocked_minutes": "Demasiadas tentativas. Tente novamente daqui a {count, plural, one {# minuto} other {# minutos}}."
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"desktop_browser_sign_in": {
|
||||
"button": "Entrar com o navegador…",
|
||||
"waiting": "A aguardar o navegador…",
|
||||
"waiting_hint": "Termine de entrar por lá. O seu gestor de palavras-passe funciona no navegador.",
|
||||
"cancel": "Cancelar",
|
||||
"or_here": "ou entre aqui",
|
||||
"timed_out": "A entrada no navegador demorou demasiado. Tente novamente.",
|
||||
"no_browser": "O Bulwark não conseguiu abrir o navegador. Tente novamente ou entre aqui.",
|
||||
"failed": "Não foi possível entrar através do navegador. Tente novamente.",
|
||||
"handoff_banner": "Um programa neste computador pediu esta sessão para a aplicação Bulwark. Este navegador continua sem sessão no Bulwark. Se não foi você que iniciou isto, feche este separador.",
|
||||
"consent_title": "Entrar na aplicação Bulwark?",
|
||||
"consent_description": "Um programa neste computador, na porta {port}, está a pedir a sessão de {username}. Continue só se acabou de carregar em «Entrar com o navegador…» na aplicação Bulwark aqui. Caso contrário, alguém pode estar a tentar entrar na sua conta: escolha Agora não.",
|
||||
"consent_confirm": "Entrar na aplicação",
|
||||
"consent_cancel": "Agora não",
|
||||
"page_done": "Pode fechar este separador e voltar ao Bulwark.",
|
||||
"page_failed": "Não foi possível entrar. Volte ao Bulwark e tente novamente.",
|
||||
"page_link_used": "Esta ligação só funciona uma vez. Volte ao Bulwark e comece de novo.",
|
||||
"page_title": "Entrar em {server}",
|
||||
"page_intro": "Esta página vem da aplicação Bulwark neste computador. A sua palavra-passe vai para {server} e para mais lado nenhum.",
|
||||
"page_username": "Endereço de e-mail ou nome de utilizador",
|
||||
"page_password": "Palavra-passe",
|
||||
"page_code": "Código de dois fatores",
|
||||
"page_submit": "Entrar",
|
||||
"page_wrong_password": "O servidor não aceitou este nome de utilizador e esta palavra-passe.",
|
||||
"page_code_needed": "Esta conta também precisa de um código de dois fatores. Introduza novamente a palavra-passe e o código.",
|
||||
"page_no_answer": "O Bulwark não conseguiu entrar. Verifique se o servidor está acessível e tente novamente.",
|
||||
"declined": "Disse que não no navegador, por isso não foi iniciada nenhuma sessão.",
|
||||
"too_many": "Demasiadas tentativas de entrada a partir desta rede. Aguarde alguns minutos e tente novamente.",
|
||||
"page_declined": "Nada foi entregue. Pode fechar este separador."
|
||||
},
|
||||
"desktop_updates": {
|
||||
"title": "Atualizações",
|
||||
"version_label": "Bulwark {version}",
|
||||
|
||||
+30
-4
@@ -6873,14 +6873,40 @@
|
||||
"blocked_minutes": "Prea multe încercări. Încercați din nou peste {count, plural, one {# minut} few {# minute} other {# de minute}}."
|
||||
},
|
||||
"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ă."
|
||||
},
|
||||
"desktop_browser_sign_in": {
|
||||
"button": "Conectați-vă cu browserul…",
|
||||
"waiting": "Se așteaptă browserul…",
|
||||
"waiting_hint": "Terminați conectarea acolo. Managerul de parole funcționează în browser.",
|
||||
"cancel": "Anulați",
|
||||
"or_here": "sau conectați-vă aici",
|
||||
"timed_out": "Conectarea în browser a durat prea mult. Încercați din nou.",
|
||||
"no_browser": "Bulwark nu a putut deschide browserul. Încercați din nou sau conectați-vă aici.",
|
||||
"failed": "Conectarea prin browser nu a reușit. Încercați din nou.",
|
||||
"handoff_banner": "Un program de pe acest computer a cerut această conectare pentru aplicația Bulwark. Acest browser rămâne deconectat de la Bulwark. Dacă nu ați pornit dvs. acest lucru, închideți această filă.",
|
||||
"consent_title": "Vă conectați la aplicația Bulwark?",
|
||||
"consent_description": "Un program de pe acest computer, la portul {port}, cere conectarea pentru {username}. Continuați doar dacă tocmai ați apăsat „Conectați-vă cu browserul…” în aplicația Bulwark de aici. Dacă nu, poate cineva încearcă să intre în contul dvs.: alegeți Nu acum.",
|
||||
"consent_confirm": "Conectați-vă la aplicație",
|
||||
"consent_cancel": "Nu acum",
|
||||
"page_done": "Puteți închide această filă și reveni la Bulwark.",
|
||||
"page_failed": "Conectarea nu a reușit. Reveniți la Bulwark și încercați din nou.",
|
||||
"page_link_used": "Acest link funcționează o singură dată. Reveniți la Bulwark și începeți din nou.",
|
||||
"page_title": "Conectați-vă la {server}",
|
||||
"page_intro": "Această pagină vine de la aplicația Bulwark de pe acest computer. Parola merge la {server} și nicăieri altundeva.",
|
||||
"page_username": "Adresă de e-mail sau nume de utilizator",
|
||||
"page_password": "Parolă",
|
||||
"page_code": "Cod cu doi factori",
|
||||
"page_submit": "Conectați-vă",
|
||||
"page_wrong_password": "Serverul nu a acceptat acest nume de utilizator și această parolă.",
|
||||
"page_code_needed": "Acest cont are nevoie și de un cod cu doi factori. Introduceți din nou parola și codul.",
|
||||
"page_no_answer": "Bulwark nu s-a putut conecta. Verificați dacă serverul este accesibil și încercați din nou.",
|
||||
"declined": "Ați refuzat în browser, așa că nu s-a făcut nicio conectare.",
|
||||
"too_many": "Prea multe încercări de conectare din această rețea. Așteptați câteva minute și încercați din nou.",
|
||||
"page_declined": "Nu s-a transmis nimic. Puteți închide această filă."
|
||||
},
|
||||
"desktop_updates": {
|
||||
"title": "Actualizări",
|
||||
"version_label": "Bulwark {version}",
|
||||
|
||||
+30
-4
@@ -6873,14 +6873,40 @@
|
||||
"blocked_minutes": "Слишком много попыток. Повторите через {count, plural, one {# минуту} few {# минуты} many {# минут} other {# минуты}}."
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "Войти через браузер",
|
||||
"browser_waiting": "Завершите вход в браузере",
|
||||
"browser_unavailable": "Этот сервер не поддерживает вход через браузер. Используйте пароль.",
|
||||
"browser_failed": "Не удалось войти через браузер. Попробуйте ещё раз.",
|
||||
"server_needed": "Сначала введите адрес сервера.",
|
||||
"server_declined": "Вы не разрешили этот сервер, поэтому ему ничего не отправлено.",
|
||||
"server_invalid": "Этот адрес сервера недействителен."
|
||||
},
|
||||
"desktop_browser_sign_in": {
|
||||
"button": "Войти через браузер…",
|
||||
"waiting": "Ожидание браузера…",
|
||||
"waiting_hint": "Завершите вход там. Ваш менеджер паролей работает в браузере.",
|
||||
"cancel": "Отмена",
|
||||
"or_here": "или войдите здесь",
|
||||
"timed_out": "Вход через браузер занял слишком много времени. Попробуйте ещё раз.",
|
||||
"no_browser": "Bulwark не смог открыть браузер. Попробуйте ещё раз или войдите здесь.",
|
||||
"failed": "Не удалось войти через браузер. Попробуйте ещё раз.",
|
||||
"handoff_banner": "Программа на этом компьютере запросила этот вход для приложения Bulwark. В этом браузере вход в Bulwark не выполняется. Если это начали не вы, закройте вкладку.",
|
||||
"consent_title": "Войти в приложение Bulwark?",
|
||||
"consent_description": "Программа на этом компьютере, на порту {port}, запрашивает вход для {username}. Продолжайте, только если вы только что нажали «Войти через браузер…» в приложении Bulwark на этом компьютере. Если нет, возможно, кто-то пытается попасть в вашу учётную запись: выберите «Не сейчас».",
|
||||
"consent_confirm": "Войти в приложение",
|
||||
"consent_cancel": "Не сейчас",
|
||||
"page_done": "Можно закрыть эту вкладку и вернуться в Bulwark.",
|
||||
"page_failed": "Не удалось войти. Вернитесь в Bulwark и попробуйте ещё раз.",
|
||||
"page_link_used": "Эта ссылка работает только один раз. Вернитесь в Bulwark и начните заново.",
|
||||
"page_title": "Вход на {server}",
|
||||
"page_intro": "Эта страница открыта приложением Bulwark на этом компьютере. Ваш пароль уходит на {server} и больше никуда.",
|
||||
"page_username": "Адрес эл. почты или имя пользователя",
|
||||
"page_password": "Пароль",
|
||||
"page_code": "Двухфакторный код",
|
||||
"page_submit": "Войти",
|
||||
"page_wrong_password": "Сервер не принял это имя пользователя и пароль.",
|
||||
"page_code_needed": "Для этой учётной записи также нужен двухфакторный код. Введите пароль ещё раз и код.",
|
||||
"page_no_answer": "Bulwark не смог войти. Проверьте, доступен ли сервер, и попробуйте ещё раз.",
|
||||
"declined": "Вы отказали в браузере, поэтому вход не выполнен.",
|
||||
"too_many": "Слишком много попыток входа из этой сети. Подождите несколько минут и попробуйте ещё раз.",
|
||||
"page_declined": "Ничего не передано. Можно закрыть эту вкладку."
|
||||
},
|
||||
"desktop_updates": {
|
||||
"title": "Обновления",
|
||||
"version_label": "Bulwark {version}",
|
||||
|
||||
+30
-4
@@ -6873,14 +6873,40 @@
|
||||
"blocked_minutes": "Príliš veľa pokusov. Skúste to o {count, plural, one {# minútu} few {# minúty} many {# minúty} other {# minút}}."
|
||||
},
|
||||
"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á."
|
||||
},
|
||||
"desktop_browser_sign_in": {
|
||||
"button": "Prihlásiť sa v prehliadači…",
|
||||
"waiting": "Čaká sa na prehliadač…",
|
||||
"waiting_hint": "Dokončite prihlásenie tam. Váš správca hesiel funguje v prehliadači.",
|
||||
"cancel": "Zrušiť",
|
||||
"or_here": "alebo sa prihláste tu",
|
||||
"timed_out": "Prihlásenie v prehliadači trvalo príliš dlho. Skúste to znova.",
|
||||
"no_browser": "Bulwark nemohol otvoriť prehliadač. Skúste to znova alebo sa prihláste tu.",
|
||||
"failed": "Prihlásenie cez prehliadač sa nepodarilo. Skúste to znova.",
|
||||
"handoff_banner": "Program v tomto počítači požiadal o toto prihlásenie pre aplikáciu Bulwark. Tento prehliadač zostane z Bulwarku odhlásený. Ak ste to nespustili vy, zavrite túto kartu.",
|
||||
"consent_title": "Prihlásiť sa do aplikácie Bulwark?",
|
||||
"consent_description": "Program v tomto počítači, na porte {port}, žiada o prihlásenie pre {username}. Pokračujte, len ak ste práve klikli na „Prihlásiť sa v prehliadači…“ v aplikácii Bulwark v tomto počítači. Ak nie, možno sa niekto snaží dostať do vášho účtu: zvoľte Teraz nie.",
|
||||
"consent_confirm": "Prihlásiť sa do aplikácie",
|
||||
"consent_cancel": "Teraz nie",
|
||||
"page_done": "Túto kartu môžete zavrieť a vrátiť sa do Bulwarku.",
|
||||
"page_failed": "Prihlásenie sa nepodarilo. Vráťte sa do Bulwarku a skúste to znova.",
|
||||
"page_link_used": "Tento odkaz funguje len raz. Vráťte sa do Bulwarku a začnite znova.",
|
||||
"page_title": "Prihlásiť sa na {server}",
|
||||
"page_intro": "Táto stránka pochádza z aplikácie Bulwark v tomto počítači. Vaše heslo ide na {server} a nikam inam.",
|
||||
"page_username": "E-mailová adresa alebo používateľské meno",
|
||||
"page_password": "Heslo",
|
||||
"page_code": "Dvojfaktorový kód",
|
||||
"page_submit": "Prihlásiť sa",
|
||||
"page_wrong_password": "Server toto používateľské meno a heslo neprijal.",
|
||||
"page_code_needed": "Tento účet potrebuje aj dvojfaktorový kód. Zadajte znova heslo a kód.",
|
||||
"page_no_answer": "Bulwark sa nemohol prihlásiť. Skontrolujte, či je server dostupný, a skúste to znova.",
|
||||
"declined": "V prehliadači ste odmietli, takže k prihláseniu nedošlo.",
|
||||
"too_many": "Príliš veľa pokusov o prihlásenie z tejto siete. Počkajte niekoľko minút a skúste to znova.",
|
||||
"page_declined": "Nič nebolo odovzdané. Túto kartu môžete zavrieť."
|
||||
},
|
||||
"desktop_updates": {
|
||||
"title": "Aktualizácie",
|
||||
"version_label": "Bulwark {version}",
|
||||
|
||||
+30
-4
@@ -6881,14 +6881,40 @@
|
||||
"blocked_minutes": "ลองหลายครั้งเกินไป ลองอีกครั้งใน {count, plural, other {# นาที}}"
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "ลงชื่อเข้าใช้ด้วยเบราว์เซอร์",
|
||||
"browser_waiting": "ลงชื่อเข้าใช้ให้เสร็จในเบราว์เซอร์",
|
||||
"browser_unavailable": "เซิร์ฟเวอร์นี้ไม่รองรับการลงชื่อเข้าใช้ผ่านเบราว์เซอร์ โปรดใช้รหัสผ่าน",
|
||||
"browser_failed": "ลงชื่อเข้าใช้ผ่านเบราว์เซอร์ไม่สำเร็จ โปรดลองอีกครั้ง",
|
||||
"server_needed": "ป้อนที่อยู่เซิร์ฟเวอร์ก่อน",
|
||||
"server_declined": "คุณไม่ได้อนุญาตเซิร์ฟเวอร์นี้ จึงไม่มีการส่งข้อมูลใดไป",
|
||||
"server_invalid": "ที่อยู่เซิร์ฟเวอร์นี้ไม่ถูกต้อง"
|
||||
},
|
||||
"desktop_browser_sign_in": {
|
||||
"button": "ลงชื่อเข้าใช้ด้วยเบราว์เซอร์…",
|
||||
"waiting": "กำลังรอเบราว์เซอร์…",
|
||||
"waiting_hint": "ลงชื่อเข้าใช้ให้เสร็จในเบราว์เซอร์ ตัวจัดการรหัสผ่านของคุณใช้งานได้ที่นั่น",
|
||||
"cancel": "ยกเลิก",
|
||||
"or_here": "หรือลงชื่อเข้าใช้ที่นี่",
|
||||
"timed_out": "การลงชื่อเข้าใช้ในเบราว์เซอร์ใช้เวลานานเกินไป ลองอีกครั้ง",
|
||||
"no_browser": "Bulwark เปิดเบราว์เซอร์ไม่ได้ ลองอีกครั้งหรือลงชื่อเข้าใช้ที่นี่",
|
||||
"failed": "ลงชื่อเข้าใช้ผ่านเบราว์เซอร์ไม่สำเร็จ ลองอีกครั้ง",
|
||||
"handoff_banner": "โปรแกรมบนคอมพิวเตอร์เครื่องนี้ขอการลงชื่อเข้าใช้นี้สำหรับแอป Bulwark เบราว์เซอร์นี้จะยังไม่ได้ลงชื่อเข้าใช้ Bulwark หากคุณไม่ได้เริ่มเอง ให้ปิดแท็บนี้",
|
||||
"consent_title": "ลงชื่อเข้าใช้แอป Bulwark หรือไม่",
|
||||
"consent_description": "โปรแกรมบนคอมพิวเตอร์เครื่องนี้ที่พอร์ต {port} กำลังขอการลงชื่อเข้าใช้ของ {username} ดำเนินการต่อเฉพาะเมื่อคุณเพิ่งกด \"ลงชื่อเข้าใช้ด้วยเบราว์เซอร์…\" ในแอป Bulwark บนเครื่องนี้ หากไม่ได้กด อาจมีคนพยายามเข้าบัญชีของคุณ ให้เลือก ไม่ใช่ตอนนี้",
|
||||
"consent_confirm": "ลงชื่อเข้าใช้แอป",
|
||||
"consent_cancel": "ไม่ใช่ตอนนี้",
|
||||
"page_done": "คุณปิดแท็บนี้และกลับไปที่ Bulwark ได้",
|
||||
"page_failed": "ลงชื่อเข้าใช้ไม่สำเร็จ กลับไปที่ Bulwark แล้วลองอีกครั้ง",
|
||||
"page_link_used": "ลิงก์นี้ใช้ได้ครั้งเดียว กลับไปที่ Bulwark แล้วเริ่มใหม่",
|
||||
"page_title": "ลงชื่อเข้าใช้ {server}",
|
||||
"page_intro": "หน้านี้มาจากแอป Bulwark บนคอมพิวเตอร์เครื่องนี้ รหัสผ่านของคุณจะส่งไปที่ {server} เท่านั้น",
|
||||
"page_username": "ที่อยู่อีเมลหรือชื่อผู้ใช้",
|
||||
"page_password": "รหัสผ่าน",
|
||||
"page_code": "รหัสสองปัจจัย",
|
||||
"page_submit": "ลงชื่อเข้าใช้",
|
||||
"page_wrong_password": "เซิร์ฟเวอร์ไม่ยอมรับชื่อผู้ใช้และรหัสผ่านนี้",
|
||||
"page_code_needed": "บัญชีนี้ต้องใช้รหัสสองปัจจัยด้วย ป้อนรหัสผ่านอีกครั้งพร้อมรหัส",
|
||||
"page_no_answer": "Bulwark ลงชื่อเข้าใช้ไม่ได้ ตรวจสอบว่าเข้าถึงเซิร์ฟเวอร์ได้แล้วลองอีกครั้ง",
|
||||
"declined": "คุณปฏิเสธในเบราว์เซอร์ จึงไม่มีการลงชื่อเข้าใช้",
|
||||
"too_many": "มีการพยายามลงชื่อเข้าใช้จากเครือข่ายนี้มากเกินไป รอสักครู่แล้วลองอีกครั้ง",
|
||||
"page_declined": "ไม่มีการส่งมอบสิ่งใด คุณปิดแท็บนี้ได้"
|
||||
},
|
||||
"desktop_updates": {
|
||||
"title": "การอัปเดต",
|
||||
"version_label": "Bulwark {version}",
|
||||
|
||||
+30
-4
@@ -6873,14 +6873,40 @@
|
||||
"blocked_minutes": "Çok fazla deneme. {count, plural, one {# dakika} other {# dakika}} sonra yeniden deneyin."
|
||||
},
|
||||
"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."
|
||||
},
|
||||
"desktop_browser_sign_in": {
|
||||
"button": "Tarayıcınızla oturum açın…",
|
||||
"waiting": "Tarayıcınız bekleniyor…",
|
||||
"waiting_hint": "Oturum açmayı orada tamamlayın. Parola yöneticiniz tarayıcınızda çalışır.",
|
||||
"cancel": "İptal",
|
||||
"or_here": "veya burada oturum açın",
|
||||
"timed_out": "Tarayıcıda oturum açma çok uzun sürdü. Yeniden deneyin.",
|
||||
"no_browser": "Bulwark tarayıcınızı açamadı. Yeniden deneyin veya burada oturum açın.",
|
||||
"failed": "Tarayıcınız üzerinden oturum açılamadı. Yeniden deneyin.",
|
||||
"handoff_banner": "Bu bilgisayardaki bir program bu oturum açmayı Bulwark uygulaması için istedi. Bu tarayıcı Bulwark’ta oturum kapalı kalır. Bunu siz başlatmadıysanız bu sekmeyi kapatın.",
|
||||
"consent_title": "Bulwark uygulamasında oturum açılsın mı?",
|
||||
"consent_description": "Bu bilgisayardaki bir program, {port} bağlantı noktasında, {username} için oturum açma bilgisini istiyor. Yalnızca buradaki Bulwark uygulamasında az önce \"Tarayıcınızla oturum açın…\" düğmesine bastıysanız devam edin. Basmadıysanız biri hesabınıza girmeye çalışıyor olabilir: Şimdi değil seçeneğini seçin.",
|
||||
"consent_confirm": "Uygulamada oturum aç",
|
||||
"consent_cancel": "Şimdi değil",
|
||||
"page_done": "Bu sekmeyi kapatıp Bulwark’a dönebilirsiniz.",
|
||||
"page_failed": "Oturum açılamadı. Bulwark’a dönün ve yeniden deneyin.",
|
||||
"page_link_used": "Bu bağlantı yalnızca bir kez çalışır. Bulwark’a dönün ve yeniden başlayın.",
|
||||
"page_title": "{server} için oturum açın",
|
||||
"page_intro": "Bu sayfa bu bilgisayardaki Bulwark uygulamasından geliyor. Parolanız {server} sunucusuna gider, başka hiçbir yere gitmez.",
|
||||
"page_username": "E-posta adresi veya kullanıcı adı",
|
||||
"page_password": "Parola",
|
||||
"page_code": "İki aşamalı kod",
|
||||
"page_submit": "Oturum aç",
|
||||
"page_wrong_password": "Sunucu bu kullanıcı adını ve parolayı kabul etmedi.",
|
||||
"page_code_needed": "Bu hesap ayrıca iki aşamalı kod istiyor. Parolanızı ve kodu yeniden girin.",
|
||||
"page_no_answer": "Bulwark oturum açamadı. Sunucunun erişilebilir olduğunu denetleyin ve yeniden deneyin.",
|
||||
"declined": "Tarayıcınızda hayır dediniz, bu yüzden oturum açılmadı.",
|
||||
"too_many": "Bu ağdan çok fazla oturum açma denemesi yapıldı. Birkaç dakika bekleyip yeniden deneyin.",
|
||||
"page_declined": "Hiçbir şey aktarılmadı. Bu sekmeyi kapatabilirsiniz."
|
||||
},
|
||||
"desktop_updates": {
|
||||
"title": "Güncellemeler",
|
||||
"version_label": "Bulwark {version}",
|
||||
|
||||
+30
-4
@@ -6873,14 +6873,40 @@
|
||||
"blocked_minutes": "Забагато спроб. Повторіть через {count, plural, one {# хвилину} few {# хвилини} many {# хвилин} other {# хвилини}}."
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "Увійти через браузер",
|
||||
"browser_waiting": "Завершіть вхід у браузері",
|
||||
"browser_unavailable": "Цей сервер не підтримує вхід через браузер. Використайте пароль.",
|
||||
"browser_failed": "Не вдалося увійти через браузер. Спробуйте ще раз.",
|
||||
"server_needed": "Спочатку введіть адресу сервера.",
|
||||
"server_declined": "Ви не дозволили цей сервер, тому йому нічого не надіслано.",
|
||||
"server_invalid": "Ця адреса сервера недійсна."
|
||||
},
|
||||
"desktop_browser_sign_in": {
|
||||
"button": "Увійти через браузер…",
|
||||
"waiting": "Очікування браузера…",
|
||||
"waiting_hint": "Завершіть вхід там. Ваш менеджер паролів працює в браузері.",
|
||||
"cancel": "Скасувати",
|
||||
"or_here": "або увійдіть тут",
|
||||
"timed_out": "Вхід через браузер тривав надто довго. Спробуйте ще раз.",
|
||||
"no_browser": "Bulwark не зміг відкрити браузер. Спробуйте ще раз або увійдіть тут.",
|
||||
"failed": "Не вдалося увійти через браузер. Спробуйте ще раз.",
|
||||
"handoff_banner": "Програма на цьому комп’ютері запитала цей вхід для застосунку Bulwark. У цьому браузері вхід у Bulwark не виконується. Якщо це почали не ви, закрийте вкладку.",
|
||||
"consent_title": "Увійти в застосунок Bulwark?",
|
||||
"consent_description": "Програма на цьому комп’ютері, на порту {port}, запитує вхід для {username}. Продовжуйте, лише якщо ви щойно натиснули «Увійти через браузер…» у застосунку Bulwark на цьому комп’ютері. Якщо ні, можливо, хтось намагається потрапити у ваш обліковий запис: виберіть «Не зараз».",
|
||||
"consent_confirm": "Увійти в застосунок",
|
||||
"consent_cancel": "Не зараз",
|
||||
"page_done": "Можна закрити цю вкладку й повернутися до Bulwark.",
|
||||
"page_failed": "Не вдалося увійти. Поверніться до Bulwark і спробуйте ще раз.",
|
||||
"page_link_used": "Це посилання працює лише один раз. Поверніться до Bulwark і почніть знову.",
|
||||
"page_title": "Вхід на {server}",
|
||||
"page_intro": "Цю сторінку відкрив застосунок Bulwark на цьому комп’ютері. Ваш пароль іде на {server} і більше нікуди.",
|
||||
"page_username": "Адреса ел. пошти або ім’я користувача",
|
||||
"page_password": "Пароль",
|
||||
"page_code": "Двофакторний код",
|
||||
"page_submit": "Увійти",
|
||||
"page_wrong_password": "Сервер не прийняв це ім’я користувача й пароль.",
|
||||
"page_code_needed": "Для цього облікового запису також потрібен двофакторний код. Введіть пароль ще раз і код.",
|
||||
"page_no_answer": "Bulwark не зміг увійти. Перевірте, чи доступний сервер, і спробуйте ще раз.",
|
||||
"declined": "Ви відмовили в браузері, тому вхід не виконано.",
|
||||
"too_many": "Забагато спроб входу з цієї мережі. Зачекайте кілька хвилин і спробуйте ще раз.",
|
||||
"page_declined": "Нічого не передано. Можна закрити цю вкладку."
|
||||
},
|
||||
"desktop_updates": {
|
||||
"title": "Оновлення",
|
||||
"version_label": "Bulwark {version}",
|
||||
|
||||
+30
-4
@@ -6885,14 +6885,40 @@
|
||||
"blocked_minutes": "Quá nhiều lần thử. Hãy thử lại sau {count, plural, other {# phút}}."
|
||||
},
|
||||
"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ệ."
|
||||
},
|
||||
"desktop_browser_sign_in": {
|
||||
"button": "Đăng nhập bằng trình duyệt…",
|
||||
"waiting": "Đang chờ trình duyệt…",
|
||||
"waiting_hint": "Hãy hoàn tất đăng nhập ở đó. Trình quản lý mật khẩu của bạn hoạt động trong trình duyệt.",
|
||||
"cancel": "Hủy",
|
||||
"or_here": "hoặc đăng nhập tại đây",
|
||||
"timed_out": "Đăng nhập trong trình duyệt mất quá nhiều thời gian. Hãy thử lại.",
|
||||
"no_browser": "Bulwark không thể mở trình duyệt. Hãy thử lại hoặc đăng nhập tại đây.",
|
||||
"failed": "Không đăng nhập được qua trình duyệt. Hãy thử lại.",
|
||||
"handoff_banner": "Một chương trình trên máy tính này đã yêu cầu đăng nhập này cho ứng dụng Bulwark. Trình duyệt này vẫn chưa đăng nhập Bulwark. Nếu không phải bạn bắt đầu, hãy đóng tab này.",
|
||||
"consent_title": "Đăng nhập vào ứng dụng Bulwark?",
|
||||
"consent_description": "Một chương trình trên máy tính này, tại cổng {port}, đang yêu cầu thông tin đăng nhập của {username}. Chỉ tiếp tục nếu bạn vừa nhấn \"Đăng nhập bằng trình duyệt…\" trong ứng dụng Bulwark tại đây. Nếu không, có thể ai đó đang cố vào tài khoản của bạn: hãy chọn Để sau.",
|
||||
"consent_confirm": "Đăng nhập vào ứng dụng",
|
||||
"consent_cancel": "Để sau",
|
||||
"page_done": "Bạn có thể đóng tab này và quay lại Bulwark.",
|
||||
"page_failed": "Không đăng nhập được. Hãy quay lại Bulwark và thử lại.",
|
||||
"page_link_used": "Liên kết này chỉ dùng được một lần. Hãy quay lại Bulwark và bắt đầu lại.",
|
||||
"page_title": "Đăng nhập vào {server}",
|
||||
"page_intro": "Trang này đến từ ứng dụng Bulwark trên máy tính này. Mật khẩu của bạn chỉ được gửi đến {server}.",
|
||||
"page_username": "Địa chỉ email hoặc tên người dùng",
|
||||
"page_password": "Mật khẩu",
|
||||
"page_code": "Mã hai yếu tố",
|
||||
"page_submit": "Đăng nhập",
|
||||
"page_wrong_password": "Máy chủ không chấp nhận tên người dùng và mật khẩu này.",
|
||||
"page_code_needed": "Tài khoản này cũng cần mã hai yếu tố. Hãy nhập lại mật khẩu và mã.",
|
||||
"page_no_answer": "Bulwark không thể đăng nhập. Hãy kiểm tra xem máy chủ có truy cập được không rồi thử lại.",
|
||||
"declined": "Bạn đã từ chối trong trình duyệt nên không có đăng nhập nào.",
|
||||
"too_many": "Có quá nhiều lần thử đăng nhập từ mạng này. Hãy đợi vài phút rồi thử lại.",
|
||||
"page_declined": "Không có gì được chuyển giao. Bạn có thể đóng tab này."
|
||||
},
|
||||
"desktop_updates": {
|
||||
"title": "Cập nhật",
|
||||
"version_label": "Bulwark {version}",
|
||||
|
||||
@@ -6873,14 +6873,40 @@
|
||||
"blocked_minutes": "嘗試次數過多。請在 {count, plural, other {# 分鐘}}後再試。"
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "使用瀏覽器登入",
|
||||
"browser_waiting": "請在瀏覽器中完成登入",
|
||||
"browser_unavailable": "此伺服器不支援透過瀏覽器登入。請使用密碼。",
|
||||
"browser_failed": "無法透過瀏覽器登入。請再試一次。",
|
||||
"server_needed": "請先輸入伺服器位址。",
|
||||
"server_declined": "你沒有允許此伺服器,因此沒有傳送任何內容給它。",
|
||||
"server_invalid": "此伺服器位址無效。"
|
||||
},
|
||||
"desktop_browser_sign_in": {
|
||||
"button": "使用瀏覽器登入…",
|
||||
"waiting": "正在等候瀏覽器…",
|
||||
"waiting_hint": "請在瀏覽器中完成登入。你的密碼管理工具可以在瀏覽器中使用。",
|
||||
"cancel": "取消",
|
||||
"or_here": "或在此登入",
|
||||
"timed_out": "瀏覽器登入耗時過長。請再試一次。",
|
||||
"no_browser": "Bulwark 無法開啟瀏覽器。請再試一次,或在此登入。",
|
||||
"failed": "透過瀏覽器登入失敗。請再試一次。",
|
||||
"handoff_banner": "這台電腦上的一個程式為 Bulwark 應用程式要求了這次登入。此瀏覽器不會登入 Bulwark。如果不是你發起的,請關閉此分頁。",
|
||||
"consent_title": "要登入 Bulwark 應用程式嗎?",
|
||||
"consent_description": "這台電腦上連接埠 {port} 的一個程式正在要求 {username} 的登入資訊。只有在你剛剛按下這裡 Bulwark 應用程式中的「使用瀏覽器登入…」時才繼續。否則可能有人想進入你的帳戶:請選擇「暫不」。",
|
||||
"consent_confirm": "登入應用程式",
|
||||
"consent_cancel": "暫不",
|
||||
"page_done": "你可以關閉此分頁並返回 Bulwark。",
|
||||
"page_failed": "登入失敗。請返回 Bulwark 並再試一次。",
|
||||
"page_link_used": "此連結只能使用一次。請返回 Bulwark 重新開始。",
|
||||
"page_title": "登入 {server}",
|
||||
"page_intro": "此頁面來自這台電腦上的 Bulwark 應用程式。你的密碼只會傳送到 {server}。",
|
||||
"page_username": "電子郵件地址或使用者名稱",
|
||||
"page_password": "密碼",
|
||||
"page_code": "雙重驗證碼",
|
||||
"page_submit": "登入",
|
||||
"page_wrong_password": "伺服器不接受此使用者名稱和密碼。",
|
||||
"page_code_needed": "此帳戶還需要雙重驗證碼。請重新輸入密碼和驗證碼。",
|
||||
"page_no_answer": "Bulwark 無法登入。請檢查伺服器是否可連線,然後再試一次。",
|
||||
"declined": "你在瀏覽器中拒絕了,因此沒有登入。",
|
||||
"too_many": "來自此網路的登入嘗試過多。請等幾分鐘後再試一次。",
|
||||
"page_declined": "沒有交出任何內容。你可以關閉此分頁。"
|
||||
},
|
||||
"desktop_updates": {
|
||||
"title": "更新",
|
||||
"version_label": "Bulwark {version}",
|
||||
|
||||
+30
-4
@@ -6873,14 +6873,40 @@
|
||||
"blocked_minutes": "尝试次数过多。请在 {count, plural, other {# 分钟}}后重试。"
|
||||
},
|
||||
"desktop_sign_in": {
|
||||
"browser_button": "使用浏览器登录",
|
||||
"browser_waiting": "请在浏览器中完成登录",
|
||||
"browser_unavailable": "此服务器不支持通过浏览器登录。请使用密码。",
|
||||
"browser_failed": "无法通过浏览器登录。请重试。",
|
||||
"server_needed": "请先输入服务器地址。",
|
||||
"server_declined": "你没有允许此服务器,因此没有向它发送任何内容。",
|
||||
"server_invalid": "此服务器地址无效。"
|
||||
},
|
||||
"desktop_browser_sign_in": {
|
||||
"button": "使用浏览器登录…",
|
||||
"waiting": "正在等待浏览器…",
|
||||
"waiting_hint": "请在浏览器中完成登录。你的密码管理器可以在浏览器中使用。",
|
||||
"cancel": "取消",
|
||||
"or_here": "或在此登录",
|
||||
"timed_out": "浏览器登录用时过长。请重试。",
|
||||
"no_browser": "Bulwark 无法打开浏览器。请重试,或在此登录。",
|
||||
"failed": "通过浏览器登录失败。请重试。",
|
||||
"handoff_banner": "这台电脑上的一个程序为 Bulwark 应用请求了此次登录。此浏览器不会登录 Bulwark。如果不是你发起的,请关闭此标签页。",
|
||||
"consent_title": "要登录 Bulwark 应用吗?",
|
||||
"consent_description": "这台电脑上端口 {port} 的一个程序正在请求 {username} 的登录信息。只有在你刚刚点击了这里 Bulwark 应用中的“使用浏览器登录…”时才继续。否则可能有人想进入你的账户:请选择“暂不”。",
|
||||
"consent_confirm": "登录应用",
|
||||
"consent_cancel": "暂不",
|
||||
"page_done": "你可以关闭此标签页并返回 Bulwark。",
|
||||
"page_failed": "登录失败。请返回 Bulwark 并重试。",
|
||||
"page_link_used": "此链接只能使用一次。请返回 Bulwark 重新开始。",
|
||||
"page_title": "登录 {server}",
|
||||
"page_intro": "此页面来自这台电脑上的 Bulwark 应用。你的密码只会发送到 {server}。",
|
||||
"page_username": "电子邮件地址或用户名",
|
||||
"page_password": "密码",
|
||||
"page_code": "双重验证代码",
|
||||
"page_submit": "登录",
|
||||
"page_wrong_password": "服务器不接受此用户名和密码。",
|
||||
"page_code_needed": "此账户还需要双重验证代码。请重新输入密码和代码。",
|
||||
"page_no_answer": "Bulwark 无法登录。请检查服务器是否可访问,然后重试。",
|
||||
"declined": "你在浏览器中拒绝了,因此没有登录。",
|
||||
"too_many": "来自此网络的登录尝试过多。请等几分钟后重试。",
|
||||
"page_declined": "没有交出任何内容。你可以关闭此标签页。"
|
||||
},
|
||||
"desktop_updates": {
|
||||
"title": "更新",
|
||||
"version_label": "Bulwark {version}",
|
||||
|
||||
@@ -118,6 +118,11 @@ export const LITE_API_STRING_ALLOWLIST = [
|
||||
// the fragment.
|
||||
"/api/auth/handoff/create",
|
||||
"/api/auth/handoff/confirm",
|
||||
// The desktop app's sign-in on an instance: no bridge in a browser, and IS_LITE guards.
|
||||
"/api/auth/desktop-handoff/check",
|
||||
"/api/auth/desktop-handoff/discard",
|
||||
"/api/auth/desktop-handoff/park",
|
||||
"/api/auth/desktop-handoff/redeem",
|
||||
"/api/auth/totp-token-exchange",
|
||||
"/api/auth/session",
|
||||
"/api/auth/verify",
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { beforeEach, afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { JMAPClient } from '@/lib/jmap/client';
|
||||
import { generateAccountId } from '@/lib/account-utils';
|
||||
import { useAuthStore } from '../auth-store';
|
||||
import { useAccountStore } from '../account-store';
|
||||
|
||||
const SERVER = 'https://mail.example.com';
|
||||
const OPTIONS = { remember: true, rememberPassword: true, allowedServers: null, customServersAllowed: true };
|
||||
|
||||
/**
|
||||
* What the desktop app becomes after a sign-in in the browser. A typed password stays a
|
||||
* password account (#1432): its settings are there, and sign-out doesn't go to a provider.
|
||||
*/
|
||||
describe('loginWithDesktopHandoff', () => {
|
||||
let redeemAnswer: unknown;
|
||||
let redeemBody: Record<string, unknown>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
sessionStorage.clear();
|
||||
localStorage.clear();
|
||||
window.history.pushState({}, '', '/en/login');
|
||||
useAccountStore.setState({ accounts: [], activeAccountId: null, defaultAccountId: null });
|
||||
useAuthStore.setState({ isAuthenticated: false, isLoading: false, error: null, serverUrl: null, username: null, client: null, activeAccountId: null });
|
||||
vi.spyOn(JMAPClient.prototype, 'connect').mockResolvedValue(undefined as never);
|
||||
vi.spyOn(JMAPClient.prototype, 'getUsername').mockReturnValue('session-name@example.com');
|
||||
vi.spyOn(JMAPClient.prototype, 'getSessionUsername').mockReturnValue('session-name@example.com');
|
||||
vi.spyOn(JMAPClient.prototype, 'getIdentities').mockResolvedValue([{ id: 'i', name: 'Alice', email: 'alice@example.com' }] as never);
|
||||
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
if (String(input).includes('/api/auth/desktop-handoff/redeem')) {
|
||||
redeemBody = JSON.parse(String(init?.body));
|
||||
return new Response(JSON.stringify(redeemAnswer), { status: 200 });
|
||||
}
|
||||
return new Response('{}', { status: 200 });
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
const account = () => useAccountStore.getState().getActiveAccount();
|
||||
|
||||
it('tokens from a typed password are a password account, under the name typed', async () => {
|
||||
redeemAnswer = { flow: 'tokens', server_url: SERVER, access_token: 'a', expires_in: 3600, remember: true, password_login: true, username: 'alice' };
|
||||
expect(await useAuthStore.getState().loginWithDesktopHandoff('c', 'v', OPTIONS)).toBe('ok');
|
||||
expect(account()).toMatchObject({ authMode: 'oauth', providerSession: false, username: 'alice' });
|
||||
// A short login name has the same id as the password sign-in, so what was kept for it comes back.
|
||||
expect(account()?.id).toBe(generateAccountId('alice', SERVER));
|
||||
expect(account()?.id).not.toBe(generateAccountId('alice@example.com', SERVER));
|
||||
expect(useAuthStore.getState().username).toBe('alice');
|
||||
expect(account()?.serverIdentifiers?.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('a provider\'s sign-in stays a provider session, under the address the server gives', async () => {
|
||||
redeemAnswer = { flow: 'tokens', server_url: SERVER, access_token: 'a', expires_in: 3600, remember: true };
|
||||
expect(await useAuthStore.getState().loginWithDesktopHandoff('c', 'v', OPTIONS)).toBe('ok');
|
||||
expect(account()).toMatchObject({ authMode: 'oauth', providerSession: true, username: 'alice@example.com' });
|
||||
});
|
||||
|
||||
it('sends the device\'s rules with the code', async () => {
|
||||
redeemAnswer = { flow: 'tokens', server_url: SERVER, access_token: 'a', expires_in: 3600, remember: true };
|
||||
await useAuthStore.getState().loginWithDesktopHandoff('c', 'v', { ...OPTIONS, allowedServers: ['https://a.example'], customServersAllowed: false });
|
||||
expect(redeemBody).toMatchObject({ code: 'c', code_verifier: 'v', allowed_servers: ['https://a.example'], custom_servers_allowed: false });
|
||||
});
|
||||
});
|
||||
+161
-82
@@ -110,6 +110,11 @@ interface AuthState {
|
||||
loginWithToken: (serverUrl: string, token: string, rememberMe?: boolean) => Promise<boolean>;
|
||||
loginWithOAuth: (serverUrl: string, code: string, codeVerifier: string, redirectUri: string, serverId?: string) => Promise<boolean>;
|
||||
loginWithServerSso: (code: string, state: string) => Promise<boolean>;
|
||||
/**
|
||||
* The desktop app on an instance: the one-time code from its browser sign-in, with the
|
||||
* verifier it kept. `blocked` is the device's own list of servers saying no.
|
||||
*/
|
||||
loginWithDesktopHandoff: (code: string, verifier: string, options: { remember: boolean; rememberPassword: boolean; allowedServers: string[] | null; customServersAllowed: boolean }) => Promise<'ok' | 'blocked' | 'failed'>;
|
||||
loginDemo: () => Promise<boolean>;
|
||||
/**
|
||||
* Obtain a usable access token for `accountId`, or the active account.
|
||||
@@ -1625,6 +1630,118 @@ function notifySignedIn(state: { activeAccountId: string | null; username: strin
|
||||
void authHooks.onLogin.emit({ accountId: state.activeAccountId, username: state.username ?? '' });
|
||||
}
|
||||
|
||||
/**
|
||||
* A provider's tokens are in, with the refresh token already in the slot's cookie: the
|
||||
* rest of a sign-in. Server-side SSO, and the desktop app's sign-in on an instance.
|
||||
*/
|
||||
async function adoptProviderSession(
|
||||
set: (state: Partial<AuthState>) => void,
|
||||
get: () => AuthState,
|
||||
session: {
|
||||
serverUrl: string;
|
||||
accessToken: string;
|
||||
expiresIn: number;
|
||||
remember: boolean | undefined;
|
||||
slot: number;
|
||||
/**
|
||||
* Tokens from a typed password, not a provider's page: the account the user knows, under
|
||||
* the name they typed. It has the same id as any other password sign-in with that name, so
|
||||
* what its last sign-out kept comes back, and its password settings stay (#1432).
|
||||
*/
|
||||
typedUsername?: string;
|
||||
},
|
||||
): Promise<void> {
|
||||
const { serverUrl: ssoServerUrl, accessToken: access_token, expiresIn: expires_in, remember, slot } = session;
|
||||
const typed = session.typedUsername ? toAsciiEmail(session.typedUsername) : '';
|
||||
const accountStore = useAccountStore.getState();
|
||||
let boundAccountId: string | undefined;
|
||||
const client = JMAPClient.withBearer(ssoServerUrl, access_token, typed, () => get().refreshAccessToken({ accountId: boundAccountId }));
|
||||
await client.connect();
|
||||
|
||||
const jmapUsername = client.getUsername();
|
||||
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), typed || jmapUsername);
|
||||
// For SSO/OIDC, the JMAP session account name may be the
|
||||
// preferred_username claim rather than the real email address.
|
||||
// Prefer the email from the primary identity when available.
|
||||
const username = typed || primaryIdentity?.email || jmapUsername;
|
||||
initializeFeatureStores(client);
|
||||
|
||||
const accountId = generateAccountId(username, ssoServerUrl);
|
||||
boundAccountId = accountId;
|
||||
|
||||
const prevAccountId = get().activeAccountId;
|
||||
if (prevAccountId && prevAccountId !== accountId) {
|
||||
snapshotAccount(prevAccountId);
|
||||
clearAllStores();
|
||||
}
|
||||
|
||||
holdClient(accountId, client);
|
||||
bindClientStatusHandlers(client, set, get, accountId);
|
||||
|
||||
accountStore.addAccount({
|
||||
label: primaryIdentity?.name || username,
|
||||
serverUrl: ssoServerUrl,
|
||||
username,
|
||||
authMode: 'oauth',
|
||||
rememberMe: remember !== false,
|
||||
displayName: primaryIdentity?.name || username,
|
||||
email: primaryIdentity?.email || username,
|
||||
lastLoginAt: Date.now(),
|
||||
isConnected: true,
|
||||
hasError: false,
|
||||
isDefault: accountStore.accounts.length === 0,
|
||||
});
|
||||
// The refresh-token cookie was written to `slot` by /api/auth/sso/complete.
|
||||
// Force the stored cookieSlot to match - see loginWithOAuth above for the
|
||||
// re-add and concurrent-tab cases this guards against.
|
||||
accountStore.updateAccount(accountId, { cookieSlot: slot, providerSession: !typed });
|
||||
if (typed) {
|
||||
const serverIdentifiers = buildServerIdentifiers(client.getSessionUsername(), primaryIdentity?.email, ssoServerUrl);
|
||||
if (serverIdentifiers.length > 0) accountStore.updateAccount(accountId, { serverIdentifiers });
|
||||
}
|
||||
accountStore.setActiveAccount(accountId);
|
||||
|
||||
await syncStalwartAuthContext(ssoServerUrl, username, client.getAuthHeader(), slot);
|
||||
void syncAccountDisplayName(accountId, client, primaryIdentity?.name);
|
||||
|
||||
set({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
serverUrl: ssoServerUrl,
|
||||
username,
|
||||
client,
|
||||
...getClientRateLimitState(client),
|
||||
identities,
|
||||
primaryIdentity,
|
||||
authMode: 'oauth',
|
||||
accessToken: access_token,
|
||||
tokenExpiresAt: Date.now() + expires_in * 1000,
|
||||
connectionLost: false,
|
||||
error: null,
|
||||
activeAccountId: accountId,
|
||||
});
|
||||
|
||||
import('@/stores/email-store').then(({ useEmailStore }) => {
|
||||
useEmailStore.getState().prefetchInitialData(client).catch((err) => {
|
||||
debug.error('Initial data prefetch failed:', err);
|
||||
});
|
||||
}).catch(() => {});
|
||||
|
||||
scheduleRefresh(expires_in, () => get().refreshAccessToken({ accountId }), accountId);
|
||||
|
||||
notifyParent('sso:auth-success', { username });
|
||||
|
||||
fetchConfig().then(cfg => {
|
||||
if (!cfg.settingsSyncEnabled) return;
|
||||
useSettingsStore.getState().loadFromServer(username, ssoServerUrl).finally(() => {
|
||||
useSettingsStore.getState().enableSync(username, ssoServerUrl);
|
||||
applyPreferredIdentity(accountId);
|
||||
});
|
||||
}).catch(() => {});
|
||||
|
||||
notifySignedIn(get());
|
||||
}
|
||||
|
||||
function beforeLogoutHooks(accountId: string, options: SignOutCleanupOptions = {}): Promise<void> {
|
||||
return Promise.race([
|
||||
Promise.all([authHooks.onBeforeLogout.emit({ accountId }), runBeforeSignOut(accountId, options)]).then(() => {}),
|
||||
@@ -2291,88 +2408,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
throw new Error('Server URL not configured');
|
||||
}
|
||||
|
||||
let boundAccountId: string | undefined;
|
||||
const client = JMAPClient.withBearer(ssoServerUrl, access_token, '', () => get().refreshAccessToken({ accountId: boundAccountId }));
|
||||
await client.connect();
|
||||
|
||||
const jmapUsername = client.getUsername();
|
||||
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), jmapUsername);
|
||||
// For SSO/OIDC, the JMAP session account name may be the
|
||||
// preferred_username claim rather than the real email address.
|
||||
// Prefer the email from the primary identity when available.
|
||||
const username = primaryIdentity?.email || jmapUsername;
|
||||
initializeFeatureStores(client);
|
||||
|
||||
const accountId = generateAccountId(username, ssoServerUrl);
|
||||
boundAccountId = accountId;
|
||||
|
||||
const prevAccountId = get().activeAccountId;
|
||||
if (prevAccountId && prevAccountId !== accountId) {
|
||||
snapshotAccount(prevAccountId);
|
||||
clearAllStores();
|
||||
}
|
||||
|
||||
holdClient(accountId, client);
|
||||
bindClientStatusHandlers(client, set, get, accountId);
|
||||
|
||||
accountStore.addAccount({
|
||||
label: primaryIdentity?.name || username,
|
||||
serverUrl: ssoServerUrl,
|
||||
username,
|
||||
authMode: 'oauth',
|
||||
rememberMe: remember !== false,
|
||||
displayName: primaryIdentity?.name || username,
|
||||
email: primaryIdentity?.email || username,
|
||||
lastLoginAt: Date.now(),
|
||||
isConnected: true,
|
||||
hasError: false,
|
||||
isDefault: accountStore.accounts.length === 0,
|
||||
});
|
||||
// The refresh-token cookie was written to `slot` by /api/auth/sso/complete.
|
||||
// Force the stored cookieSlot to match - see loginWithOAuth above for the
|
||||
// re-add and concurrent-tab cases this guards against.
|
||||
accountStore.updateAccount(accountId, { cookieSlot: slot, providerSession: true });
|
||||
accountStore.setActiveAccount(accountId);
|
||||
|
||||
await syncStalwartAuthContext(ssoServerUrl, username, client.getAuthHeader(), slot);
|
||||
void syncAccountDisplayName(accountId, client, primaryIdentity?.name);
|
||||
|
||||
set({
|
||||
isAuthenticated: true,
|
||||
isLoading: false,
|
||||
serverUrl: ssoServerUrl,
|
||||
username,
|
||||
client,
|
||||
...getClientRateLimitState(client),
|
||||
identities,
|
||||
primaryIdentity,
|
||||
authMode: 'oauth',
|
||||
accessToken: access_token,
|
||||
tokenExpiresAt: Date.now() + expires_in * 1000,
|
||||
connectionLost: false,
|
||||
error: null,
|
||||
activeAccountId: accountId,
|
||||
});
|
||||
|
||||
import('@/stores/email-store').then(({ useEmailStore }) => {
|
||||
useEmailStore.getState().prefetchInitialData(client).catch((err) => {
|
||||
debug.error('Initial data prefetch failed:', err);
|
||||
});
|
||||
}).catch(() => {});
|
||||
|
||||
scheduleRefresh(expires_in, () => get().refreshAccessToken({ accountId }), accountId);
|
||||
|
||||
notifyParent('sso:auth-success', { username });
|
||||
|
||||
fetchConfig().then(cfg => {
|
||||
if (!cfg.settingsSyncEnabled) return;
|
||||
useSettingsStore.getState().loadFromServer(username, ssoServerUrl).finally(() => {
|
||||
useSettingsStore.getState().enableSync(username, ssoServerUrl);
|
||||
applyPreferredIdentity(accountId);
|
||||
});
|
||||
}).catch(() => {});
|
||||
|
||||
notifySignedIn(get());
|
||||
await adoptProviderSession(set, get, { serverUrl: ssoServerUrl, accessToken: access_token, expiresIn: expires_in, remember, slot });
|
||||
return true;
|
||||
} catch (error) {
|
||||
debug.error('Server SSO login error:', error);
|
||||
@@ -2390,6 +2426,49 @@ export const useAuthStore = create<AuthState>()(
|
||||
}
|
||||
},
|
||||
|
||||
loginWithDesktopHandoff: async (code, verifier, { remember, rememberPassword, allowedServers, customServersAllowed }) => {
|
||||
if (IS_LITE) return 'failed';
|
||||
set({ isLoading: true, error: null, isRateLimited: false, rateLimitUntil: null });
|
||||
try {
|
||||
const slot = useAccountStore.getState().getNextCookieSlot();
|
||||
const res = await apiFetch('/api/auth/desktop-handoff/redeem', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code, code_verifier: verifier, slot, remember, allowed_servers: allowedServers, custom_servers_allowed: customServersAllowed }),
|
||||
}).then((answer) => noteSessionAnswer(answer, slot));
|
||||
if (!res.ok) {
|
||||
const refusal = (await res.json().catch(() => null)) as { error?: string } | null;
|
||||
set({ isLoading: false, error: null });
|
||||
return refusal?.error === 'blocked_by_policy' ? 'blocked' : 'failed';
|
||||
}
|
||||
const parked = await res.json();
|
||||
if (parked.flow === 'password') {
|
||||
// A password the browser checked a moment ago, down the same path as a typed one.
|
||||
return (await get().login(parked.server_url, parked.username, parked.password, undefined, rememberPassword)) ? 'ok' : 'failed';
|
||||
}
|
||||
await adoptProviderSession(set, get, {
|
||||
serverUrl: parked.server_url,
|
||||
accessToken: parked.access_token,
|
||||
expiresIn: parked.expires_in,
|
||||
remember: parked.remember,
|
||||
slot,
|
||||
...(parked.password_login ? { typedUsername: parked.username } : {}),
|
||||
});
|
||||
return 'ok';
|
||||
} catch (error) {
|
||||
debug.error('Desktop hand-off login error:', error);
|
||||
set({
|
||||
isLoading: false,
|
||||
error: error instanceof Error ? error.message : 'generic',
|
||||
isAuthenticated: false,
|
||||
isRateLimited: false,
|
||||
rateLimitUntil: null,
|
||||
client: null,
|
||||
});
|
||||
return 'failed';
|
||||
}
|
||||
},
|
||||
|
||||
refreshAccessToken: async (options) => {
|
||||
// Every multi-account client and timer passes its own account: renewing
|
||||
// the active slot on a background account's behalf would hand that
|
||||
|
||||
Reference in New Issue
Block a user