diff --git a/app/(main)/admin/_pages/auth.tsx b/app/(main)/admin/_pages/auth.tsx
index 9bfdcd684..8a1a4d63d 100644
--- a/app/(main)/admin/_pages/auth.tsx
+++ b/app/(main)/admin/_pages/auth.tsx
@@ -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)
+
+ On Stalwart 1.0 this also registers a client for Bulwark Mobile: bulwark-mobile, no secret, redirect URI org.bulwarkmail.mobile:/auth/callback.
+
diff --git a/app/api/admin/oauth/setup/__tests__/route.test.ts b/app/api/admin/oauth/setup/__tests__/route.test.ts
index c3a416fb1..93f46b35f 100644
--- a/app/api/admin/oauth/setup/__tests__/route.test.ts
+++ b/app/api/admin/oauth/setup/__tests__/route.test.ts
@@ -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 = {};
const jmapCalls: Array<[string, Record]> = [];
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>).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>).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>).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>).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>).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 () => {
diff --git a/app/api/admin/oauth/setup/route.ts b/app/api/admin/oauth/setup/route.ts
index 074b5cfdf..7bedf5419 100644
--- a/app/api/admin/oauth/setup/route.ts
+++ b/app/api/admin/oauth/setup/route.ts
@@ -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; notUpdated?: Record } | 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' });