From c6eb02d4cd96defc2587d8438c1c2d011fd5e2b9 Mon Sep 17 00:00:00 2001 From: Linus Rath <139418639+rathlinus@users.noreply.github.com> Date: Thu, 29 Oct 2026 02:07:47 +0100 Subject: [PATCH] fix: say why account security is unavailable when Stalwart 1.0 limits the app's sign-in --- locales/rn/en.json | 1 + src/api/__tests__/account-security.test.ts | 77 +++++++++++++++++++ src/api/account-security.ts | 42 +++++++++- src/api/jmap-client.ts | 5 ++ .../settings/AccountSecuritySettings.tsx | 37 ++++++++- 5 files changed, 155 insertions(+), 7 deletions(-) create mode 100644 src/api/__tests__/account-security.test.ts diff --git a/locales/rn/en.json b/locales/rn/en.json index 414c3c27..87010918 100644 --- a/locales/rn/en.json +++ b/locales/rn/en.json @@ -1199,6 +1199,7 @@ "remove_title": "Remove public key?", "title": "Public Keys" }, + "restricted": "Your server lets only apps its administrator approved change the password, two-factor sign-in, app passwords and API keys. Bulwark signed in without that approval, so mail, contacts, calendars and files work, but these settings don't. Change them in your server's web interface or the Bulwark webmail.", "two_factor": { "active": "Active", "code": "Verification code", diff --git a/src/api/__tests__/account-security.test.ts b/src/api/__tests__/account-security.test.ts new file mode 100644 index 00000000..b09f1d29 --- /dev/null +++ b/src/api/__tests__/account-security.test.ts @@ -0,0 +1,77 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { fake } = vi.hoisted(() => ({ + fake: { + accountId: 'h', + isConnected: true, + currentSession: {} as object | null, + oauthClientId: null as string | null, + stalwart: true, + responses: [] as unknown[], + hasAccountCapability(cap: string) { + return cap === 'urn:stalwart:jmap' && this.stalwart; + }, + async request() { + return { methodResponses: this.responses }; + }, + }, +})); + +vi.mock('../jmap-client', () => ({ jmapClient: fake })); + +import { AccountSecurityForbiddenError, fetchAuthInfo, isLimitedStalwartSignIn } from '../account-security'; + +const queries = [ + ['x:AppPassword/query', { ids: [] }, 'aq'], + ['x:ApiKey/query', { ids: [] }, 'kq'], +]; + +describe('fetchAuthInfo', () => { + beforeEach(() => { + fake.stalwart = true; + fake.oauthClientId = null; + fake.currentSession = {}; + }); + + it('says the sign-in is refused, not that something broke, when Stalwart answers forbidden', async () => { + fake.responses = [ + ['error', { type: 'forbidden', description: 'You are not authorized to perform this action' }, 'p'], + ...queries, + ]; + await expect(fetchAuthInfo()).rejects.toBeInstanceOf(AccountSecurityForbiddenError); + }); + + it('reads two-factor as on from otpAuth.otpUrl', async () => { + fake.responses = [['x:AccountPassword/get', { list: [{ otpAuth: { otpUrl: '****' } }] }, 'p'], ...queries]; + expect((await fetchAuthInfo()).otpEnabled).toBe(true); + }); + + it("reads two-factor as on from 1.0's top-level otpUrl", async () => { + fake.responses = [['x:AccountPassword/get', { list: [{ otpUrl: '****' }] }, 'p'], ...queries]; + expect((await fetchAuthInfo()).otpEnabled).toBe(true); + }); + + it('reads two-factor as off when neither is set', async () => { + fake.responses = [['x:AccountPassword/get', { list: [{ otpAuth: {} }] }, 'p'], ...queries]; + expect((await fetchAuthInfo()).otpEnabled).toBe(false); + }); +}); + +describe('isLimitedStalwartSignIn', () => { + it('is true for a client Stalwart registered when the session hides the extension', () => { + fake.oauthClientId = 'swc1.abc'; + fake.stalwart = false; + expect(isLimitedStalwartSignIn()).toBe(true); + }); + + it('is false when the extension is there, or the client is not a registered one', () => { + fake.oauthClientId = 'swc1.abc'; + fake.stalwart = true; + expect(isLimitedStalwartSignIn()).toBe(false); + fake.oauthClientId = 'bulwark-webmail'; + fake.stalwart = false; + expect(isLimitedStalwartSignIn()).toBe(false); + fake.oauthClientId = null; + expect(isLimitedStalwartSignIn()).toBe(false); + }); +}); diff --git a/src/api/account-security.ts b/src/api/account-security.ts index e0e41447..3665d3b7 100644 --- a/src/api/account-security.ts +++ b/src/api/account-security.ts @@ -81,6 +81,31 @@ export function isStalwartSupported(): boolean { return jmapClient.hasAccountCapability(STALWART_CAPABILITY); } +/** + * The server refused an x:* method to this sign-in. Stalwart 1.0 gives the + * account's own settings (password, two-factor, app passwords, API keys, + * encryption) only to first-party clients; a token from the client the app + * registers itself reaches mail, contacts, calendars and files, and every + * x:* call answers `forbidden`. Basic auth and 0.16 tokens are not limited. + */ +export class AccountSecurityForbiddenError extends Error { + constructor(message: string) { + super(message); + this.name = 'AccountSecurityForbiddenError'; + } +} + +/** + * Signed in to Stalwart 1.0 through the client the app registered itself, + * whose tokens reach no x:* object: the session then lists no Stalwart + * extension either (checked on 1.0.0), which is not the same as a server + * without it. Stalwart's registered client ids are `swc1.` sealed ids. + */ +export function isLimitedStalwartSignIn(): boolean { + const clientId = jmapClient.oauthClientId; + return !!clientId && clientId.startsWith('swc1.') && !isStalwartSupported() && !!jmapClient.currentSession; +} + // Pull a single method response out by its call id and throw on a JMAP method // error (the `['error', {type, description}, id]` envelope). function resultFor>(responses: MethodResponse[], callId: string): T { @@ -88,7 +113,9 @@ function resultFor>(responses: MethodResponse[], callId: if (!match) throw new Error(`Missing JMAP response for call ${callId}`); if (match[0] === 'error') { const err = match[1] as { type?: string; description?: string }; - throw new Error(err.description || err.type || 'JMAP error'); + const message = err.description || err.type || 'JMAP error'; + if (err.type === 'forbidden') throw new AccountSecurityForbiddenError(message); + throw new Error(message); } return match[1] as T; } @@ -142,12 +169,19 @@ export async function fetchAuthInfo(): Promise { ['x:ApiKey/query', { accountId }, 'kq'], ]); - const passwordResult = resultFor<{ list?: Array<{ otpAuth?: { otpUrl?: string | null } }> }>(responses, 'p'); + const passwordResult = resultFor<{ + list?: Array<{ otpAuth?: { otpUrl?: string | null }; otpUrl?: string | null }>; + }>(responses, 'p'); const appPwQuery = resultFor<{ ids?: string[] }>(responses, 'aq'); const apiKeyQuery = resultFor<{ ids?: string[] }>(responses, 'kq'); - const otpAuth = passwordResult.list?.[0]?.otpAuth; - const otpEnabled = !!(otpAuth && typeof otpAuth === 'object' && otpAuth.otpUrl); + // x:AccountPassword nests the TOTP URL in `otpAuth` on 0.16 and 1.0. 1.0's + // PasswordCredential (the admin's view of the same secret) has it as a + // top-level `otpUrl` instead, so take either. + const entry = passwordResult.list?.[0]; + const otpAuth = entry?.otpAuth; + const otpEnabled = !!(otpAuth && typeof otpAuth === 'object' && otpAuth.otpUrl) + || (typeof entry?.otpUrl === 'string' && !!entry.otpUrl); const followUps: [string, Record, string][] = []; if (appPwQuery.ids?.length) { diff --git a/src/api/jmap-client.ts b/src/api/jmap-client.ts index 55133a7c..8998da4a 100644 --- a/src/api/jmap-client.ts +++ b/src/api/jmap-client.ts @@ -201,6 +201,11 @@ export class JMAPClient { return !!this.credentials?.accessToken && !this.credentials.tokenEndpoint; } + /** The OAuth client the session's tokens were issued to (null without a bundle). */ + get oauthClientId(): string | null { + return (this.credentials?.accessToken && this.credentials.clientId) || null; + } + get isConnected(): boolean { return this.session !== null && this._accountId !== null; } diff --git a/src/components/settings/AccountSecuritySettings.tsx b/src/components/settings/AccountSecuritySettings.tsx index 45a7fcef..49ad9d9d 100644 --- a/src/components/settings/AccountSecuritySettings.tsx +++ b/src/components/settings/AccountSecuritySettings.tsx @@ -19,6 +19,8 @@ import { } from '../../lib/client-cert'; import { useLocaleStore } from '../../stores/locale-store'; import { + AccountSecurityForbiddenError, + isLimitedStalwartSignIn, isStalwartSupported, fetchAuthInfo, fetchCryptoInfo, @@ -845,6 +847,9 @@ export function AccountSecuritySettings() { // null = still probing; false = server lacks the Stalwart extension. const [supported, setSupported] = useState(null); + // The server has the extension but refuses it to this sign-in (Stalwart + // 1.0 with a client that isn't first-party). + const [restricted, setRestricted] = useState(false); const [auth, setAuth] = useState(null); const t = useLocaleStore((s) => s.t); const [displayName, setDisplayName] = useState(''); @@ -872,8 +877,18 @@ export function AccountSecuritySettings() { const session = jmapClient.currentSession; if (!session) { setOffline(true); setSupported(false); return; } setOffline(false); - if (!isStalwartSupported()) { setSupported(false); return; } + if (!isStalwartSupported()) { + // Stalwart 1.0 hides its extension from the client the app registered. + if (isLimitedStalwartSignIn()) { + setSupported(true); + setRestricted(true); + return; + } + setSupported(false); + return; + } setSupported(true); + setRestricted(false); (async () => { try { @@ -888,7 +903,12 @@ export function AccountSecuritySettings() { if (principal.status === 'fulfilled') setDisplayName(principal.value.displayName); } } catch (err) { - if (!cancelled) setLoadError(err instanceof Error ? err.message : t('settings.security.load_error', 'Failed to load security settings.')); + if (cancelled) return; + if (err instanceof AccountSecurityForbiddenError) { + setRestricted(true); + return; + } + setLoadError(err instanceof Error ? err.message : t('settings.security.load_error', 'Failed to load security settings.')); } })(); @@ -919,7 +939,18 @@ export function AccountSecuritySettings() { )} - {supported && ( + {supported && restricted && ( + + + {t('settings.security.restricted', "Your server lets only apps its administrator approved change the password, two-factor sign-in, app passwords and API keys. Bulwark signed in without that approval, so mail, contacts, calendars and files work, but these settings don't. Change them in your server's web interface or the Bulwark webmail.")} + + + )} + + {supported && !restricted && (