fix: stop automatic SSO towards a server off the device's list and say why in OAuth-only mode

This commit is contained in:
Linus Rath
2026-11-03 14:32:14 +01:00
parent cd3bd33307
commit 4e86e8189a
3 changed files with 123 additions and 8 deletions
@@ -0,0 +1,112 @@
import React from 'react';
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { BulwarkDesktopBridge } from '@/lib/desktop/types';
import { EMPTY_POLICY_LAYERS, resolvePolicies } from '@/lib/desktop/policy-schema';
// The device's OS policy on the two ways into SSO: nothing starts towards a refused server.
vi.mock('next/navigation', () => ({
useParams: () => ({ locale: 'en' }),
useSearchParams: () => new URLSearchParams(),
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }),
usePathname: () => '/login',
}));
vi.mock('@/i18n/navigation', () => ({
useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }),
usePathname: () => '/login',
Link: ({ children }: { children: React.ReactNode }) => children,
}));
const configOverrides = vi.hoisted(() => ({ value: {} as Record<string, unknown> }));
vi.mock('@/hooks/use-config', () => ({
useConfig: () => ({
appName: 'Webmail',
isLoading: false,
error: null,
jmapServerUrl: 'https://mail.example.org',
jmapServers: [],
oauthEnabled: true,
oauthOnly: true,
oauthClientId: 'webmail',
...configOverrides.value,
}),
}));
const { default: LoginPage } = await import('../page');
const METADATA = {
issuer: 'https://mail.example.org',
authorization_endpoint: 'https://mail.example.org/authorize',
token_endpoint: 'https://mail.example.org/token',
};
function allowOnly(...servers: string[]) {
const snapshot = resolvePolicies({ ...EMPTY_POLICY_LAYERS, forcedMachine: { AllowedJmapServers: servers } });
window.bulwarkDesktop = {
version: 1,
platform: 'win32',
mode: 'standalone',
policy: { get: () => snapshot, onChange: () => () => {} },
} as unknown as BulwarkDesktopBridge;
}
let fetched: string[];
beforeEach(() => {
configOverrides.value = {};
fetched = [];
sessionStorage.clear();
vi.stubGlobal('fetch', vi.fn(async (input: RequestInfo | URL) => {
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/sso/start')) return new Response(JSON.stringify({ authorize_url: 'about:blank#sso' }), { status: 200 });
return new Response('{}', { status: 200 });
}));
});
afterEach(() => {
vi.unstubAllGlobals();
window.bulwarkDesktop = undefined;
});
const ssoButton = () => screen.findByRole('button', { name: /sign_in_sso/ }, { timeout: 10000 });
const startedPkce = () => sessionStorage.getItem('oauth_code_verifier');
const startedSso = () => fetched.some((url) => url.includes('/api/auth/sso/start'));
describe('the SSO button', () => {
it('starts nothing towards a server off the list, and says why', async () => {
allowOnly('https://other.example.org');
render(<LoginPage />);
fireEvent.click(await ssoButton());
expect(await screen.findByText('server_refused')).toBeInTheDocument();
expect(startedPkce()).toBeNull();
}, 30_000);
it('starts as usual towards a server on the list', async () => {
allowOnly('https://mail.example.org');
render(<LoginPage />);
fireEvent.click(await ssoButton());
await waitFor(() => expect(startedPkce()).not.toBeNull());
expect(screen.queryByText('server_refused')).toBeNull();
}, 30_000);
});
describe('automatic SSO', () => {
it('asks the server for nothing when its server is off the list, and says why', async () => {
configOverrides.value = { autoSsoEnabled: true };
allowOnly('https://other.example.org');
render(<LoginPage />);
expect(await screen.findByText('server_refused', {}, { timeout: 10000 })).toBeInTheDocument();
expect(startedSso()).toBe(false);
}, 30_000);
it('starts as usual when its server is on the list', async () => {
configOverrides.value = { autoSsoEnabled: true };
allowOnly('https://mail.example.org');
render(<LoginPage />);
await waitFor(() => expect(startedSso()).toBe(true), { timeout: 10000 });
}, 30_000);
});
+10 -7
View File
@@ -640,7 +640,7 @@ function LoginPageContent() {
const startServerSideSso = useCallback(async () => {
// The device's OS policy, as for a password: nothing starts towards a refused server.
if (selectedServer?.url && !jmapServerAllowedByDesktopPolicy(selectedServer.url, desktopPolicy)) {
if (serverUrl && !jmapServerAllowedByDesktopPolicy(serverUrl, desktopPolicy)) {
setDesktopPolicyNotice(tDesktopPolicy("server_refused"));
return;
}
@@ -700,7 +700,7 @@ function LoginPageContent() {
} catch {
setOauthLoading(false);
}
}, [params.locale, selectedServer?.id, selectedServer?.url, desktopPolicy, tDesktopPolicy, persistProviderSession, isMobileHandoff, mobileRedirectUri, mobileState, mobileChallenge]);
}, [params.locale, selectedServer?.id, serverUrl, desktopPolicy, tDesktopPolicy, persistProviderSession, isMobileHandoff, mobileRedirectUri, mobileState, mobileChallenge]);
useEffect(() => {
// After signing out, show the login page rather than signing straight
@@ -1174,6 +1174,12 @@ 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).
const desktopPolicyAlert = desktopPolicyNotice && (
<p role="alert" data-testid="desktop-policy-notice" className="mt-2 text-sm text-warning leading-relaxed">
{desktopPolicyNotice}
</p>
);
const serverPicker = hasServerList && jmapServers.length > 1 ? (
<div className="space-y-1.5">
<label htmlFor="jmap-server-select" className="block text-sm font-medium text-foreground">
@@ -1588,6 +1594,7 @@ function LoginPageContent() {
/* OAuth-only mode: server picker (if any) plus the SSO button */
<div className="space-y-4">
{serverPicker}
{desktopPolicyAlert}
{oauthMetadata && showRememberMe && rememberMeBox}
{oauthMetadata ? (
<Button
@@ -1917,11 +1924,7 @@ function LoginPageContent() {
{desktopWaiting ? tDesktop("browser_waiting") : tDesktop("browser_button")}
</Button>
)}
{desktopPolicyNotice && (
<p role="alert" data-testid="desktop-policy-notice" className="mt-2 text-sm text-warning leading-relaxed">
{desktopPolicyNotice}
</p>
)}
{desktopPolicyAlert}
{IS_DESKTOP_BUILD && desktopNotice && (
<p role="alert" data-testid="desktop-notice" className="mt-2 text-sm text-warning leading-relaxed">
{desktopNotice}
+1 -1
View File
@@ -827,7 +827,7 @@ export const POLICY_CONSUMERS: Record<PolicyKey, PolicyConsumer> = {
CrashReportsAllowed: { pendingConsumer: 'step 7, telemetry consent' },
QuickComposeShortcut: { pendingConsumer: 'step 7, quick compose' },
AutoSelectCertificateForUrls: { pendingConsumer: 'step 7, client certificates' },
KeystoreAllowedOrigins: { pendingConsumer: 'feat/native-helper: setKeystoreAllowedOrigins in src/main/features/keystore.ts' },
KeystoreAllowedOrigins: { repo: 'shell', file: 'src/main/features/policies.ts' },
};
/** The part the embedded server enforces. Sent to it by message. */