feat: register a client for bulwark mobile in the oauth auto-setup #1459

This commit is contained in:
Linus Rath
2026-10-31 00:56:49 +01:00
parent 3406580a3f
commit cabb174108
3 changed files with 140 additions and 7 deletions
+9 -1
View File
@@ -138,9 +138,14 @@ export function AuthPage() {
});
const data = await res.json();
if (res.ok) {
const mobile = data.mobileClient === 'created' || data.mobileClient === 'updated'
? ` Bulwark Mobile client ${data.mobileClient} (${data.mobileClientId}, ${data.mobileRedirectUri}).`
: data.mobileClient === 'failed'
? ` Stalwart refused the Bulwark Mobile client (${data.mobileClientId}); the app registers its own instead, without access to account settings.`
: '';
setMessage({
type: 'success',
text: `OAuth client ${data.action} on Stalwart (${data.issuerUrl}). ${data.redirectUriCount} redirect URI(s) registered for ${data.origin}.`,
text: `OAuth client ${data.action} on Stalwart (${data.issuerUrl}). ${data.redirectUriCount} redirect URI(s) registered for ${data.origin}.${mobile}`,
});
setEdits({});
setSetupOpen(false);
@@ -256,6 +261,9 @@ export function AuthPage() {
/>
Also enable “OAuth only” (hide password login)
</label>
<p className="text-[0.6875rem] text-muted-foreground">
On Stalwart 1.0 this also registers a client for Bulwark Mobile: <code>bulwark-mobile</code>, no secret, redirect URI <code>org.bulwarkmail.mobile:/auth/callback</code>.
</p>
</div>
</Dialog>
@@ -8,6 +8,9 @@ let readOnly = false;
let saveError: Error | null = null;
let stalwart016 = false;
let signedIn = true;
let mobileRefused = false;
// Registry ids of the clients Stalwart already has, by client id.
let existing: Record<string, string> = {};
const jmapCalls: Array<[string, Record<string, unknown>]> = [];
vi.mock('@/lib/admin/session', () => ({
@@ -42,7 +45,13 @@ vi.mock('@/lib/stalwart/server-fetch', () => ({
}
const [name, args] = JSON.parse(init.body!).methodCalls[0];
jmapCalls.push([name, args]);
if (name === 'x:OAuthClient/query') return Response.json({ methodResponses: [[name, { ids: [] }, '0']] });
if (name === 'x:OAuthClient/query') {
const id = existing[(args.filter as { clientId: string }).clientId];
return Response.json({ methodResponses: [[name, { ids: id ? [id] : [] }, '0']] });
}
if (mobileRefused && JSON.stringify(args).includes('bulwark-mobile')) {
return Response.json({ methodResponses: [[name, { notCreated: { mobile: { type: 'invalidProperties', properties: ['redirectUris'] } } }, '0']] });
}
// Stalwart 0.16 has no firstParty and refuses it (answer seen in its source:
// PatchError "Invalid property" -> invalidPatch naming the path).
if (stalwart016 && JSON.stringify(args).includes('firstParty')) {
@@ -66,14 +75,56 @@ beforeEach(() => {
saveError = null;
stalwart016 = false;
signedIn = true;
mobileRefused = false;
existing = {};
jmapCalls.length = 0;
});
const setCalls = () => jmapCalls.filter(([name]) => name === 'x:OAuthClient/set').map(([, args]) => args);
describe('POST /api/admin/oauth/setup', () => {
it('creates the client on Stalwart when the config is writable', async () => {
const res = await setup();
expect(res.status).toBe(200);
expect(jmapCalls.map(([name]) => name)).toEqual(['x:OAuthClient/query', 'x:OAuthClient/set']);
expect(jmapCalls.slice(0, 2).map(([name]) => name)).toEqual(['x:OAuthClient/query', 'x:OAuthClient/set']);
expect((setCalls()[0].create as Record<string, Record<string, unknown>>).new.clientId).toBe('bulwark-webmail');
});
it('registers a public first-party client for Bulwark Mobile on Stalwart 1.0 (#1459)', async () => {
const res = await setup();
expect(res.status).toBe(200);
expect((await res.json()).mobileClient).toBe('created');
const mobile = (setCalls()[1].create as Record<string, Record<string, unknown>>).mobile;
expect(mobile).toMatchObject({
clientId: 'bulwark-mobile',
firstParty: true,
redirectUris: { 'org.bulwarkmail.mobile:/auth/callback': true },
});
// The app has no secret to send; a client with one fails its token exchange.
expect(mobile).not.toHaveProperty('secret');
});
it('takes over an existing Bulwark Mobile client and drops its secret', async () => {
existing = { 'bulwark-mobile': 'm' };
const res = await setup();
expect((await res.json()).mobileClient).toBe('updated');
const update = (setCalls()[1].update as Record<string, Record<string, unknown>>).m;
expect(update).toMatchObject({ secret: null, firstParty: true, redirectUris: { 'org.bulwarkmail.mobile:/auth/callback': true } });
});
it('still sets up the webmail when Stalwart refuses the mobile client', async () => {
mobileRefused = true;
const res = await setup();
expect(res.status).toBe(200);
const body = await res.json();
expect(body.ok).toBe(true);
expect(body.mobileClient).toBe('failed');
});
it('never adds the mobile redirect URI to the webmail client', async () => {
await setup();
const webmail = (setCalls()[0].create as Record<string, Record<string, unknown>>).new;
expect(Object.keys(webmail.redirectUris as object).every((uri) => uri.startsWith('https://webmail.example.org/'))).toBe(true);
});
it('registers a first-party client, which Stalwart 1.0 gives full access', async () => {
@@ -87,10 +138,12 @@ describe('POST /api/admin/oauth/setup', () => {
stalwart016 = true;
const res = await setup();
expect(res.status).toBe(200);
// No mobile client either: first-party clients are a 1.0 thing.
expect(jmapCalls.map(([name]) => name)).toEqual(['x:OAuthClient/query', 'x:OAuthClient/set', 'x:OAuthClient/set']);
const create = (jmapCalls[2][1].create as Record<string, Record<string, unknown>>).new;
expect(create.clientId).toBe('bulwark-webmail');
expect(create).not.toHaveProperty('firstParty');
expect((await res.json()).mobileClient).toBe('skipped');
});
it('tells the admin to sign in to the webmail when no mail account is signed in here', async () => {
+76 -4
View File
@@ -11,7 +11,15 @@ import { locales as ALL_LOCALES } from '@/i18n/routing';
const CLIENT_ID = 'bulwark-webmail';
const CLIENT_DESCRIPTION = 'Bulwark Webmail (auto-configured)';
// Bulwark Mobile's own client (#1459). The webmail's can't serve the app: it
// has a secret, which Stalwart wants at the token endpoint and the app
// doesn't have. The redirect is the app's App Store and Play id as a
// private-use scheme (RFC 8252), which the iOS and Android apps register.
const MOBILE_CLIENT_ID = 'bulwark-mobile';
const MOBILE_CLIENT_DESCRIPTION = 'Bulwark Mobile (auto-configured)';
const MOBILE_REDIRECT_URI = 'org.bulwarkmail.mobile:/auth/callback';
const JMAP_TIMEOUT_MS = 10_000;
const STALWART_USING = ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'];
interface JmapMethodCall {
using: string[];
@@ -98,6 +106,55 @@ function refusedFirstParty(res: JmapMethodResponse): boolean {
&& error.properties.some((p) => String(p).replace(/^\//, '') === 'firstParty'));
}
/**
* A public first-party client for Bulwark Mobile, so the app's own sign-in
* on Stalwart 1.0 reaches the account's Stalwart settings too. The client
* the app registers for itself otherwise gets the data scopes only. An
* existing one is taken over as the webmail's is, its secret dropped.
*/
async function setUpMobileClient(
creds: ServerCreds,
accountId: string,
contact: string,
): Promise<'created' | 'updated' | 'failed'> {
try {
const queryRes = await jmapCall(creds, {
using: STALWART_USING,
methodCalls: [['x:OAuthClient/query', { accountId, filter: { clientId: MOBILE_CLIENT_ID } }, '0']],
});
const queryEntry = queryRes.methodResponses?.[0];
if (!queryEntry || queryEntry[0] === 'error') return 'failed';
const existingId = ((queryEntry[1].ids as string[] | undefined) ?? [])[0];
const fields = {
description: MOBILE_CLIENT_DESCRIPTION,
redirectUris: { [MOBILE_REDIRECT_URI]: true },
firstParty: true,
};
const setRes = await jmapCall(creds, {
using: STALWART_USING,
methodCalls: [['x:OAuthClient/set', existingId
? { accountId, update: { [existingId]: { ...fields, secret: null } } }
: { accountId, create: { mobile: { clientId: MOBILE_CLIENT_ID, contacts: { [contact]: true }, ...fields } } },
'0']],
});
const setEntry = setRes.methodResponses?.[0];
const setBody = setEntry?.[1] as { notCreated?: Record<string, unknown>; notUpdated?: Record<string, unknown> } | undefined;
if (!setEntry || setEntry[0] === 'error'
|| Object.keys(setBody?.notCreated ?? {}).length > 0
|| Object.keys(setBody?.notUpdated ?? {}).length > 0) {
logger.warn('Admin OAuth setup: mobile client refused', { detail: JSON.stringify(setEntry?.[1] ?? null).slice(0, 300) });
return 'failed';
}
return existingId ? 'updated' : 'created';
} catch (error) {
logger.warn('Admin OAuth setup: mobile client failed', {
error: error instanceof Error ? error.message : 'Unknown error',
});
return 'failed';
}
}
function isValidOriginUrl(value: string): boolean {
return /^https?:\/\/[^/]+$/.test(value);
}
@@ -169,7 +226,7 @@ export async function POST(request: NextRequest) {
}
const queryRes = await jmapCall(creds, {
using: ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
using: STALWART_USING,
methodCalls: [[
'x:OAuthClient/query',
{ accountId, filter: { clientId: CLIENT_ID } },
@@ -224,12 +281,16 @@ export async function POST(request: NextRequest) {
};
};
const callSet = (firstParty: boolean) => jmapCall(creds, {
using: ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
using: STALWART_USING,
methodCalls: [['x:OAuthClient/set', buildSetArgs(firstParty), '0']],
});
let firstParty = true;
let setRes = await callSet(true);
if (refusedFirstParty(setRes)) setRes = await callSet(false);
if (refusedFirstParty(setRes)) {
firstParty = false;
setRes = await callSet(false);
}
const setEntry = setRes.methodResponses?.[0];
if (!setEntry || setEntry[0] === 'error') {
@@ -274,7 +335,7 @@ export async function POST(request: NextRequest) {
const createdId = setBody.created?.new?.id;
if (createdId) {
await jmapCall(creds, {
using: ['urn:ietf:params:jmap:core', 'urn:stalwart:jmap'],
using: STALWART_USING,
methodCalls: [['x:OAuthClient/set', { accountId, destroy: [createdId] }, '0']],
}).catch((rollbackError) => {
logger.error('Admin OAuth setup rollback failed', {
@@ -286,6 +347,12 @@ export async function POST(request: NextRequest) {
throw error;
}
// Only Stalwart 1.0 has first-party clients. The webmail is set up by
// now, so a refused mobile client is reported, not an error.
const mobileClient = firstParty
? await setUpMobileClient(creds, accountId, creds.username)
: 'skipped';
await auditLog('admin.oauth_setup', {
action,
clientId: CLIENT_ID,
@@ -293,6 +360,7 @@ export async function POST(request: NextRequest) {
issuer: issuerUrl,
redirectUriCount: localeList.length,
oauthOnly,
mobileClient,
}, ip);
logger.info('Admin OAuth setup', {
@@ -301,6 +369,7 @@ export async function POST(request: NextRequest) {
origin,
issuer: issuerUrl,
locales: localeList.length,
mobileClient,
});
return NextResponse.json({
@@ -310,6 +379,9 @@ export async function POST(request: NextRequest) {
origin,
issuerUrl,
redirectUriCount: localeList.length,
mobileClient,
mobileClientId: MOBILE_CLIENT_ID,
mobileRedirectUri: MOBILE_REDIRECT_URI,
});
} catch (error) {
logger.error('Admin OAuth setup error', { error: error instanceof Error ? error.message : 'Unknown error' });