mirror of
https://github.com/bulwarkmail/native.git
synced 2026-11-01 00:07:01 +00:00
fix: say why account security is unavailable when Stalwart 1.0 limits the app's sign-in
This commit is contained in:
@@ -1199,6 +1199,7 @@
|
|||||||
"remove_title": "Remove public key?",
|
"remove_title": "Remove public key?",
|
||||||
"title": "Public Keys"
|
"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": {
|
"two_factor": {
|
||||||
"active": "Active",
|
"active": "Active",
|
||||||
"code": "Verification code",
|
"code": "Verification code",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -81,6 +81,31 @@ export function isStalwartSupported(): boolean {
|
|||||||
return jmapClient.hasAccountCapability(STALWART_CAPABILITY);
|
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
|
// Pull a single method response out by its call id and throw on a JMAP method
|
||||||
// error (the `['error', {type, description}, id]` envelope).
|
// error (the `['error', {type, description}, id]` envelope).
|
||||||
function resultFor<T = Record<string, any>>(responses: MethodResponse[], callId: string): T {
|
function resultFor<T = Record<string, any>>(responses: MethodResponse[], callId: string): T {
|
||||||
@@ -88,7 +113,9 @@ function resultFor<T = Record<string, any>>(responses: MethodResponse[], callId:
|
|||||||
if (!match) throw new Error(`Missing JMAP response for call ${callId}`);
|
if (!match) throw new Error(`Missing JMAP response for call ${callId}`);
|
||||||
if (match[0] === 'error') {
|
if (match[0] === 'error') {
|
||||||
const err = match[1] as { type?: string; description?: string };
|
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;
|
return match[1] as T;
|
||||||
}
|
}
|
||||||
@@ -142,12 +169,19 @@ export async function fetchAuthInfo(): Promise<AuthInfo> {
|
|||||||
['x:ApiKey/query', { accountId }, 'kq'],
|
['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 appPwQuery = resultFor<{ ids?: string[] }>(responses, 'aq');
|
||||||
const apiKeyQuery = resultFor<{ ids?: string[] }>(responses, 'kq');
|
const apiKeyQuery = resultFor<{ ids?: string[] }>(responses, 'kq');
|
||||||
|
|
||||||
const otpAuth = passwordResult.list?.[0]?.otpAuth;
|
// x:AccountPassword nests the TOTP URL in `otpAuth` on 0.16 and 1.0. 1.0's
|
||||||
const otpEnabled = !!(otpAuth && typeof otpAuth === 'object' && otpAuth.otpUrl);
|
// 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, unknown>, string][] = [];
|
const followUps: [string, Record<string, unknown>, string][] = [];
|
||||||
if (appPwQuery.ids?.length) {
|
if (appPwQuery.ids?.length) {
|
||||||
|
|||||||
@@ -201,6 +201,11 @@ export class JMAPClient {
|
|||||||
return !!this.credentials?.accessToken && !this.credentials.tokenEndpoint;
|
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 {
|
get isConnected(): boolean {
|
||||||
return this.session !== null && this._accountId !== null;
|
return this.session !== null && this._accountId !== null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ import {
|
|||||||
} from '../../lib/client-cert';
|
} from '../../lib/client-cert';
|
||||||
import { useLocaleStore } from '../../stores/locale-store';
|
import { useLocaleStore } from '../../stores/locale-store';
|
||||||
import {
|
import {
|
||||||
|
AccountSecurityForbiddenError,
|
||||||
|
isLimitedStalwartSignIn,
|
||||||
isStalwartSupported,
|
isStalwartSupported,
|
||||||
fetchAuthInfo,
|
fetchAuthInfo,
|
||||||
fetchCryptoInfo,
|
fetchCryptoInfo,
|
||||||
@@ -845,6 +847,9 @@ export function AccountSecuritySettings() {
|
|||||||
|
|
||||||
// null = still probing; false = server lacks the Stalwart extension.
|
// null = still probing; false = server lacks the Stalwart extension.
|
||||||
const [supported, setSupported] = useState<boolean | null>(null);
|
const [supported, setSupported] = useState<boolean | null>(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<AuthInfo | null>(null);
|
const [auth, setAuth] = useState<AuthInfo | null>(null);
|
||||||
const t = useLocaleStore((s) => s.t);
|
const t = useLocaleStore((s) => s.t);
|
||||||
const [displayName, setDisplayName] = useState('');
|
const [displayName, setDisplayName] = useState('');
|
||||||
@@ -872,8 +877,18 @@ export function AccountSecuritySettings() {
|
|||||||
const session = jmapClient.currentSession;
|
const session = jmapClient.currentSession;
|
||||||
if (!session) { setOffline(true); setSupported(false); return; }
|
if (!session) { setOffline(true); setSupported(false); return; }
|
||||||
setOffline(false);
|
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);
|
setSupported(true);
|
||||||
|
setRestricted(false);
|
||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
@@ -888,7 +903,12 @@ export function AccountSecuritySettings() {
|
|||||||
if (principal.status === 'fulfilled') setDisplayName(principal.value.displayName);
|
if (principal.status === 'fulfilled') setDisplayName(principal.value.displayName);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} 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() {
|
|||||||
</SettingsSection>
|
</SettingsSection>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{supported && (
|
{supported && restricted && (
|
||||||
|
<SettingsSection
|
||||||
|
title={t('settings.security.title', 'Account Security')}
|
||||||
|
description={t('settings.security.description', 'Manage your password, two-factor authentication, and security settings')}
|
||||||
|
>
|
||||||
|
<Text style={styles.emptyText}>
|
||||||
|
{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.")}
|
||||||
|
</Text>
|
||||||
|
</SettingsSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{supported && !restricted && (
|
||||||
<SettingsSection
|
<SettingsSection
|
||||||
title={t('settings.security.title', 'Account Security')}
|
title={t('settings.security.title', 'Account Security')}
|
||||||
description={t('settings.security.description', 'Manage your password, two-factor authentication, and security settings')}
|
description={t('settings.security.description', 'Manage your password, two-factor authentication, and security settings')}
|
||||||
|
|||||||
Reference in New Issue
Block a user