mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-01 08:12:43 +00:00
feat: register a client for bulwark mobile in the oauth auto-setup #1459
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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' });
|
||||
|
||||
Reference in New Issue
Block a user