feat: sign in through the bulwark-mobile client on stalwart 1.0 (bulwarkmail/webmail#1459)

This commit is contained in:
Linus Rath
2026-10-31 00:57:06 +01:00
parent 9d547b9d16
commit 7a42a15f8a
3 changed files with 150 additions and 52 deletions
+33 -9
View File
@@ -17,8 +17,10 @@
* started.
*
* What differs between the lines (checked on 0.16.27 and 1.0.0):
* - 1.0 knows only registered clients, so the app registers its own public
* client (RFC 7591); 0.16 takes `bulwark-webmail`.
* - 1.0 knows only registered clients, so the app signs in as
* `bulwark-mobile` where the webmail's admin OAuth setup registered it
* (webmail #1459), else registers its own public client (RFC 7591); 0.16
* takes `bulwark-webmail`. The run checks whichever the server has.
* - 1.0 rotates refresh tokens and revokes the whole grant when a superseded
* one comes back after its successor was used.
* - 1.0 has a revocation endpoint; 0.16 has none.
@@ -49,6 +51,7 @@ import {
} from '../account-security';
import {
DEFAULT_CLIENT_ID,
MOBILE_CLIENT_ID,
REGISTERED_REDIRECT_URI,
exchangePasswordForTokens,
resolveLoginClient,
@@ -116,6 +119,14 @@ async function nextTotpCode(): Promise<string> {
// Stalwart 1.0 is the line with RP-initiated logout; 0.16 has none.
let isV1 = false;
// A 1.0 where the webmail's admin OAuth setup registered `bulwark-mobile`.
let hasMobileClient = false;
/** The client a 1.0 sign-in must have used. */
function expectV1Client(clientId: string | null | undefined): void {
if (hasMobileClient) expect(clientId).toBe(MOBILE_CLIENT_ID);
else expect(clientId).toMatch(/^swc1\./);
}
function useSingleton(client: JMAPClient): void {
(jmapClient as unknown as JMAPClient).restoreSnapshot(client.snapshot());
@@ -140,6 +151,18 @@ live('live Stalwart sign-in', () => {
beforeAll(async () => {
const metadata = await discoverOAuthMetadata(SERVER);
isV1 = !!(metadata as { end_session_endpoint?: string } | null)?.end_session_endpoint;
if (isV1) {
const query = new URLSearchParams({
response_type: 'code',
client_id: MOBILE_CLIENT_ID,
redirect_uri: REGISTERED_REDIRECT_URI,
state: 's',
code_challenge: 'A'.repeat(43),
code_challenge_method: 'S256',
});
const check = (await (await fetch(`${SERVER}/api/auth/client?${query}`)).json()) as { error?: string };
hasMobileClient = !check.error;
}
// Optional so the suite also runs against older sign-in code, to show
// what it does on a server.
resetLoginClients?.();
@@ -166,7 +189,7 @@ live('live Stalwart sign-in', () => {
it('signs in with a client the server accepts, on a redirect it accepts', async () => {
const client = await resolveLoginClient(SERVER);
if (isV1) {
expect(client.clientId).toMatch(/^swc1\./);
expectV1Client(client.clientId);
expect(client.redirectUri).toBe(REGISTERED_REDIRECT_URI);
// The browser sign-in's authorization request with this client and
// the scopes the app asks for passes 1.0's client check.
@@ -182,7 +205,8 @@ live('live Stalwart sign-in', () => {
});
const check = (await (await fetch(`${SERVER}/api/auth/client?${query}`)).json()) as { error?: string; scopes?: string[] };
expect(check.error).toBeUndefined();
expect(check.scopes?.length).toBeGreaterThan(1);
// A first-party client shows as one scope, "Full access to your account".
expect(check.scopes?.length).toBeGreaterThan(hasMobileClient ? 0 : 1);
} else {
expect(client).toEqual({ clientId: DEFAULT_CLIENT_ID, redirectUri: HANDOFF_REDIRECT_URI });
}
@@ -208,7 +232,7 @@ live('live Stalwart sign-in', () => {
expect(tokens.accessToken).toBeTruthy();
expect(tokens.refreshToken).toBeTruthy();
expect(tokens.tokenEndpoint).toBe(`${SERVER}/auth/token`);
if (isV1) expect(tokens.clientId).toMatch(/^swc1\./);
if (isV1) expectV1Client(tokens.clientId);
else expect(tokens.clientId).toBe(DEFAULT_CLIENT_ID);
});
@@ -237,9 +261,9 @@ live('live Stalwart sign-in', () => {
expect(await client.forceRefreshToken()).toBe(true);
}, 30_000);
it('account security: 1.0 refuses it to the registered client, 0.16 allows it', async () => {
it('account security: 1.0 refuses it to the registered client, allows it to bulwark-mobile; 0.16 allows it', async () => {
useSingleton(client);
if (isV1) {
if (isV1 && !hasMobileClient) {
// The session hides the extension from this client, and the calls
// themselves are refused.
expect(isStalwartSupported()).toBe(false);
@@ -317,7 +341,7 @@ live('live Stalwart sign-in', () => {
const authUrl = new URL(browser.mock.calls[0][0] as string);
expect(`${authUrl.origin}${authUrl.pathname}`).toBe(metadata.authorization_endpoint);
if (isV1) {
expect(authUrl.searchParams.get('client_id')).toMatch(/^swc1\./);
expectV1Client(authUrl.searchParams.get('client_id'));
expect(metadata.registration_endpoint).toBeTruthy();
} else {
// 0.16 on :8016 names its public address, not the one typed here, and
@@ -355,7 +379,7 @@ live('live Stalwart sign-in', () => {
tokenSource: string;
};
expect(stored.tokenSource).toBe('totp');
if (isV1) expect(stored.clientId).toMatch(/^swc1\./);
if (isV1) expectV1Client(stored.clientId);
else expect(stored.clientId).toBe(DEFAULT_CLIENT_ID);
const res = await client.request([['Mailbox/get', { accountId: client.accountId, properties: ['role'] }, '0']]);
expect(res.methodResponses[0][0]).toBe('Mailbox/get');
+52 -6
View File
@@ -7,6 +7,7 @@ import {
resolveLoginClient,
TotpLoginError,
REGISTERED_REDIRECT_URI,
MOBILE_CLIENT_ID,
} from '../totp-login';
import { describeLoginError } from '../login-errors';
@@ -31,19 +32,28 @@ function json(body: unknown, status = 200): Response {
interface Call { url: string; method: string; body?: string }
// A fake Stalwart of either line. `v1` knows no client but the ones it
// registered (ids `swc1.<n>`), takes `{query, consent}` and answers with a
// registered (ids `swc1.<n>`) and those in `admin` (registered by an admin
// for the given redirect URI), takes `{query, consent}` and answers with a
// location; 0.16 has no client check and answers `client_code`.
function fakeServer(version: '0.16' | '1.0', answer?: (query: URLSearchParams) => unknown) {
function fakeServer(
version: '0.16' | '1.0',
answer?: (query: URLSearchParams) => unknown,
admin: Record<string, string> = {},
) {
const calls: Call[] = [];
let registered = 0;
const knows = (clientId: string, redirectUri: string | null) =>
clientId.startsWith('swc1.') || (clientId in admin && admin[clientId] === redirectUri);
fetchMock.mockImplementation(async (url: string, init?: RequestInit) => {
const call = { url, method: init?.method ?? 'GET', body: typeof init?.body === 'string' ? init.body : undefined };
calls.push(call);
const path = url.slice(BASE.length).split('?')[0];
if (path === '/api/auth/client') {
if (version === '0.16') return json({ type: 'about:blank', status: 404 }, 404);
const clientId = new URL(url).searchParams.get('client_id') ?? '';
return json(clientId.startsWith('swc1.') ? { name: 'Bulwark Mobile' } : { error: 'invalid_client', description: 'Unknown client id', redirect: false });
const params = new URL(url).searchParams;
return json(knows(params.get('client_id') ?? '', params.get('redirect_uri'))
? { name: 'Bulwark Mobile' }
: { error: 'invalid_client', description: 'Unknown client id', redirect: false });
}
if (path === '/auth/register') {
if (version === '0.16') return json({ error: 'invalid_redirect_uri' }, 400);
@@ -56,7 +66,7 @@ function fakeServer(version: '0.16' | '1.0', answer?: (query: URLSearchParams) =
if (typeof body.query !== 'string') return json({ title: 'Invalid parameters', detail: 'JSON deserialization failed' }, 400);
const query = new URLSearchParams(body.query);
if (answer) return json(answer(query));
if (!query.get('client_id')?.startsWith('swc1.')) return json({ type: 'invalid', error: 'invalid_client', description: 'Unknown client id' });
if (!knows(query.get('client_id') ?? '', query.get('redirect_uri'))) return json({ type: 'invalid', error: 'invalid_client', description: 'Unknown client id' });
return json({
type: 'authenticated',
location: `${query.get('redirect_uri')}?code=code-10&state=${query.get('state')}&iss=x`,
@@ -111,6 +121,21 @@ describe('exchangePasswordForTokens', () => {
expect(tokenCall(calls).get('redirect_uri')).toBe(REGISTERED_REDIRECT_URI);
});
it('signs in on Stalwart 1.0 through the bulwark-mobile client the webmail setup registered (webmail #1459)', async () => {
const calls = fakeServer('1.0', undefined, { 'bulwark-mobile': REGISTERED_REDIRECT_URI });
const tokens = await exchangePasswordForTokens(BASE, 'u@example.org', 'pw', '123456');
expect(tokens.clientId).toBe(MOBILE_CLIENT_ID);
expect(calls.some((c) => c.url.endsWith('/auth/register'))).toBe(false);
const query = new URLSearchParams(JSON.parse(authCalls(calls)[0].body!).query);
expect(query.get('client_id')).toBe(MOBILE_CLIENT_ID);
expect(query.get('redirect_uri')).toBe(REGISTERED_REDIRECT_URI);
expect(tokenCall(calls).get('client_id')).toBe(MOBILE_CLIENT_ID);
expect(tokenCall(calls).get('redirect_uri')).toBe(REGISTERED_REDIRECT_URI);
// A public client: there is no secret to send.
expect(tokenCall(calls).has('client_secret')).toBe(false);
});
it('keeps the registered client per server for the next sign-in', async () => {
fakeServer('1.0');
await exchangePasswordForTokens(BASE, 'u@example.org', 'pw', '123456');
@@ -289,6 +314,27 @@ describe('resolveLoginClient', () => {
const client = await resolveLoginClient(BASE, { registrationEndpoint: 'https://public.example.com/auth/register' });
expect(client.clientId).toBe('swc1.pub');
expect(urls).toEqual([`${BASE}/api/auth/client`, 'https://public.example.com/auth/register']);
expect(urls).toEqual([`${BASE}/api/auth/client`, `${BASE}/api/auth/client`, 'https://public.example.com/auth/register']);
});
it('asks for bulwark-mobile first, then the webmail client, before registering one', async () => {
const calls = fakeServer('1.0');
await resolveLoginClient(BASE);
const checked = calls
.filter((c) => c.url.startsWith(`${BASE}/api/auth/client`))
.map((c) => new URL(c.url).searchParams);
expect(checked.map((p) => [p.get('client_id'), p.get('redirect_uri')])).toEqual([
[MOBILE_CLIENT_ID, REGISTERED_REDIRECT_URI],
['bulwark-webmail', 'bulwarkmobile://auth/callback'],
]);
});
it('remembers bulwark-mobile for the next sign-in', async () => {
fakeServer('1.0', undefined, { 'bulwark-mobile': REGISTERED_REDIRECT_URI });
await resolveLoginClient(BASE);
const calls = fakeServer('1.0', undefined, { 'bulwark-mobile': REGISTERED_REDIRECT_URI });
expect((await resolveLoginClient(BASE)).clientId).toBe(MOBILE_CLIENT_ID);
expect(calls).toHaveLength(0);
});
});
+65 -37
View File
@@ -36,14 +36,23 @@ export const DEFAULT_CLIENT_ID = 'bulwark-webmail';
/**
* Redirect URI of the client the app registers at a Stalwart 1.0 that knows
* no client for it. 1.0 lets a public client register only a loopback URI or
* a private-use scheme with a dot in it (RFC 8252), which `bulwarkmobile://`
* is not; this one is the iOS bundle id. The password login never visits it
* (/api/auth hands the location back); the browser sign-in comes back on it,
* so the app registers the scheme too.
* no client for it, and of `bulwark-mobile`. 1.0 lets a public client
* register only a loopback URI or a private-use scheme with a dot in it
* (RFC 8252), which `bulwarkmobile://` is not; this one is the App Store and
* Play id. The password login never visits it (/api/auth hands the location
* back); the browser sign-in comes back on it, so the app registers the
* scheme too.
*/
export const REGISTERED_REDIRECT_URI = 'org.bulwarkmail.mobile:/auth/callback';
/**
* The app's own client, which the webmail's admin OAuth setup registers on
* Stalwart 1.0 (webmail #1459): public and first-party, so its tokens reach
* the account's Stalwart settings, which those of a client the app
* registers itself don't.
*/
export const MOBILE_CLIENT_ID = 'bulwark-mobile';
/**
* The data scopes Stalwart 1.0 grants a client that isn't first-party. A
* token reaches only what these name (no scope: nothing but the session);
@@ -189,27 +198,20 @@ async function registerPublicClient(base: string, url = `${base}/auth/register`)
}
/**
* The OAuth client to sign in to `serverUrl` as. Stalwart 1.0's client check
* (GET /api/auth/client, which 0.16 doesn't have) says whether it knows the
* webmail's client id with the app's redirect URI; when it doesn't, the app
* registers its own public client, at `registrationEndpoint` when the OAuth
* metadata names one. Any other server, and 0.16, gets `bulwark-webmail` as
* before. A server having a bad moment (5xx, 429) gets the default without
* it being remembered; an unreachable one throws `NetworkError`, as the
* default would only end in the server refusing a client it doesn't know.
* Stalwart 1.0's client check (GET /api/auth/client, which 0.16 doesn't
* have): does the server take `clientId` on `redirectUri`? `busy` for a
* server having a bad moment (5xx, 429), `none` where there is no such
* check; an unreachable server throws `NetworkError`.
*/
export async function resolveLoginClient(
serverUrl: string,
opts?: { registrationEndpoint?: string },
): Promise<LoginClient> {
const base = trimBase(serverUrl);
const cached = loginClients.get(base);
if (cached) return cached;
const fallback: LoginClient = { clientId: DEFAULT_CLIENT_ID, redirectUri: HANDOFF_REDIRECT_URI };
async function checkClient(
base: string,
clientId: string,
redirectUri: string,
): Promise<'known' | 'unknown' | 'none' | 'busy'> {
const query = new URLSearchParams({
response_type: 'code',
client_id: DEFAULT_CLIENT_ID,
redirect_uri: HANDOFF_REDIRECT_URI,
client_id: clientId,
redirect_uri: redirectUri,
state: 'probe',
code_challenge: 'A'.repeat(43),
code_challenge_method: 'S256',
@@ -228,24 +230,50 @@ export async function resolveLoginClient(
} finally {
t.done();
}
if (!response || typeof response.status !== 'number') return fallback;
if (!response || typeof response.status !== 'number') return 'busy';
assertSameServer(url, response);
if (response.status >= 500 || response.status === 429 || response.status === 408) return fallback;
if (response.status >= 500 || response.status === 429 || response.status === 408) return 'busy';
// 0.16 and servers that aren't Stalwart: no such endpoint.
if (!response.ok) {
rememberClient(base, fallback);
return fallback;
}
if (!response.ok) return 'none';
const body = (await response.json().catch(() => null)) as { error?: unknown } | null;
if (!body || typeof body !== 'object') {
rememberClient(base, fallback);
return fallback;
if (!body || typeof body !== 'object') return 'none';
// `invalid_client` (unknown id) or a redirect URI the client doesn't list.
return typeof body.error === 'string' && body.error ? 'unknown' : 'known';
}
/**
* The OAuth client to sign in to `serverUrl` as. On Stalwart 1.0 that is
* `bulwark-mobile` where the admin's OAuth setup registered it, else the
* webmail's client if it takes the app's redirect URI, else a public client
* the app registers itself, at `registrationEndpoint` when the OAuth
* metadata names one. Any other server, and 0.16, gets `bulwark-webmail` as
* before. A server having a bad moment (5xx, 429) gets the default without
* it being remembered; an unreachable one throws `NetworkError`, as the
* default would only end in the server refusing a client it doesn't know.
*/
export async function resolveLoginClient(
serverUrl: string,
opts?: { registrationEndpoint?: string },
): Promise<LoginClient> {
const base = trimBase(serverUrl);
const cached = loginClients.get(base);
if (cached) return cached;
const fallback: LoginClient = { clientId: DEFAULT_CLIENT_ID, redirectUri: HANDOFF_REDIRECT_URI };
const own = await checkClient(base, MOBILE_CLIENT_ID, REGISTERED_REDIRECT_URI);
if (own === 'busy') return fallback;
if (own === 'known') {
const client = { clientId: MOBILE_CLIENT_ID, redirectUri: REGISTERED_REDIRECT_URI };
rememberClient(base, client);
return client;
}
// `invalid_client` (unknown id) or a redirect URI the admin's client
// doesn't list: either way the webmail's client isn't the app's to use.
if (typeof body.error === 'string' && body.error) {
const endpoint = opts?.registrationEndpoint;
return registerPublicClient(base, endpoint && /^https?:\/\//i.test(endpoint) ? endpoint : undefined);
if (own === 'unknown') {
const shared = await checkClient(base, DEFAULT_CLIENT_ID, HANDOFF_REDIRECT_URI);
if (shared === 'busy') return fallback;
if (shared === 'unknown') {
const endpoint = opts?.registrationEndpoint;
return registerPublicClient(base, endpoint && /^https?:\/\//i.test(endpoint) ? endpoint : undefined);
}
}
rememberClient(base, fallback);
return fallback;