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' });