mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-10-31 16:13:15 +00:00
394 lines
14 KiB
TypeScript
394 lines
14 KiB
TypeScript
import { NextRequest, NextResponse } from 'next/server';
|
|
import { randomBytes } from 'node:crypto';
|
|
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
|
import { getStalwartCredentials, type StalwartCredentials } from '@/lib/stalwart/credentials';
|
|
import { fetchJmapServer } from '@/lib/stalwart/server-fetch';
|
|
import { configManager } from '@/lib/admin/config-manager';
|
|
import { isConfigReadOnly, ConfigReadOnlyError } from '@/lib/admin/paths';
|
|
import { auditLog } from '@/lib/admin/audit';
|
|
import { logger } from '@/lib/logger';
|
|
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[];
|
|
methodCalls: Array<[string, Record<string, unknown>, string]>;
|
|
}
|
|
|
|
interface JmapMethodResponse {
|
|
methodResponses?: Array<[string, Record<string, unknown>, string]>;
|
|
}
|
|
|
|
type ServerCreds = Pick<StalwartCredentials, 'serverUrl' | 'authHeader' | 'trusted'>;
|
|
|
|
async function fetchWithTimeout(
|
|
url: string,
|
|
init: Parameters<typeof fetch>[1],
|
|
trusted: boolean,
|
|
): Promise<Response> {
|
|
const controller = new AbortController();
|
|
const timer = setTimeout(() => controller.abort(), JMAP_TIMEOUT_MS);
|
|
try {
|
|
return await fetchJmapServer(url, { ...init, signal: controller.signal }, trusted);
|
|
} finally {
|
|
clearTimeout(timer);
|
|
}
|
|
}
|
|
|
|
async function jmapCall(
|
|
creds: ServerCreds,
|
|
body: JmapMethodCall,
|
|
): Promise<JmapMethodResponse> {
|
|
const res = await fetchWithTimeout(`${creds.serverUrl}/jmap/`, {
|
|
method: 'POST',
|
|
headers: { 'Authorization': creds.authHeader, 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(body),
|
|
}, creds.trusted);
|
|
if (!res.ok) {
|
|
const text = await res.text().catch(() => '');
|
|
throw new Error(`JMAP HTTP ${res.status} ${text.slice(0, 200)}`);
|
|
}
|
|
return res.json() as Promise<JmapMethodResponse>;
|
|
}
|
|
|
|
async function getStalwartAccountId(creds: ServerCreds): Promise<string | null> {
|
|
const res = await fetchWithTimeout(`${creds.serverUrl}/.well-known/jmap`, {
|
|
method: 'GET',
|
|
headers: { 'Authorization': creds.authHeader },
|
|
}, creds.trusted);
|
|
if (!res.ok) return null;
|
|
const session = await res.json() as { primaryAccounts?: Record<string, string> };
|
|
return session.primaryAccounts?.['urn:stalwart:jmap']
|
|
?? session.primaryAccounts?.['urn:ietf:params:jmap:mail']
|
|
?? Object.values(session.primaryAccounts ?? {})[0]
|
|
?? null;
|
|
}
|
|
|
|
function buildRedirectUris(origin: string, localeList: readonly string[]): Record<string, true> {
|
|
const out: Record<string, true> = {};
|
|
for (const loc of localeList) {
|
|
out[`${origin}/${loc}/auth/callback`] = true;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
interface SetupRequestBody {
|
|
origin?: string;
|
|
issuerUrl?: string;
|
|
locales?: string[];
|
|
oauthOnly?: boolean;
|
|
}
|
|
|
|
interface SetError {
|
|
type?: string;
|
|
properties?: unknown[];
|
|
}
|
|
|
|
/** A Stalwart that doesn't know the firstParty field (0.16) refused the set. */
|
|
function refusedFirstParty(res: JmapMethodResponse): boolean {
|
|
const body = res.methodResponses?.[0]?.[1] as
|
|
| { notCreated?: Record<string, SetError>; notUpdated?: Record<string, SetError> }
|
|
| undefined;
|
|
return Object.values({ ...body?.notCreated, ...body?.notUpdated }).some((error) =>
|
|
error?.type === 'invalidPatch'
|
|
&& Array.isArray(error.properties)
|
|
&& 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);
|
|
}
|
|
|
|
// The setup acts as the mail account signed in to this webmail, not as the
|
|
// admin: a login to the Stalwart web admin sets no cookie here.
|
|
const NOT_SIGNED_IN =
|
|
'Sign in to this webmail with a Stalwart admin account in this browser, then retry. '
|
|
+ 'A login to the Stalwart web admin does not count.';
|
|
|
|
/** Which mail account the setup would run as, so the page can say before it starts. */
|
|
export async function GET(request: NextRequest) {
|
|
const auth = await requireAdminAuth(request);
|
|
if ('error' in auth) return auth.error;
|
|
const creds = await getStalwartCredentials(request);
|
|
return NextResponse.json(creds ? { signedIn: true, username: creds.username } : { signedIn: false });
|
|
}
|
|
|
|
export async function POST(request: NextRequest) {
|
|
try {
|
|
const auth = await requireAdminAuth(request);
|
|
if ('error' in auth) return auth.error;
|
|
|
|
// The setup ends by saving the client secret here. Refuse before
|
|
// touching Stalwart, or a read-only config leaves an orphaned client
|
|
// (or a rotated secret nobody stored) on the server.
|
|
if (isConfigReadOnly()) {
|
|
return NextResponse.json(
|
|
{ error: new ConfigReadOnlyError('set up OAuth').message },
|
|
{ status: 409 },
|
|
);
|
|
}
|
|
|
|
const ip = getClientIP(request);
|
|
const creds = await getStalwartCredentials(request);
|
|
if (!creds) {
|
|
return NextResponse.json({ error: NOT_SIGNED_IN }, { status: 400 });
|
|
}
|
|
|
|
const body = await request.json() as SetupRequestBody;
|
|
const origin = (body.origin ?? '').trim().replace(/\/+$/, '');
|
|
if (!isValidOriginUrl(origin)) {
|
|
return NextResponse.json(
|
|
{ error: 'Webmail origin must be a URL like "https://webmail.example.com" with no path.' },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
const issuerUrl = (body.issuerUrl ?? origin).trim().replace(/\/+$/, '');
|
|
if (!isValidOriginUrl(issuerUrl)) {
|
|
return NextResponse.json(
|
|
{ error: 'Stalwart issuer URL must be a URL like "https://mail.example.com" with no path.' },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
const localeList = Array.isArray(body.locales) && body.locales.length > 0
|
|
? body.locales.filter(l => typeof l === 'string' && /^[a-z]{2,5}(-[A-Za-z0-9]+)*$/.test(l))
|
|
: Array.from(ALL_LOCALES);
|
|
if (localeList.length === 0) {
|
|
return NextResponse.json({ error: 'No valid locales supplied.' }, { status: 400 });
|
|
}
|
|
const oauthOnly = body.oauthOnly === true;
|
|
|
|
const accountId = await getStalwartAccountId(creds);
|
|
if (!accountId) {
|
|
return NextResponse.json(
|
|
{ error: 'Could not resolve Stalwart account from JMAP session.' },
|
|
{ status: 502 },
|
|
);
|
|
}
|
|
|
|
const queryRes = await jmapCall(creds, {
|
|
using: STALWART_USING,
|
|
methodCalls: [[
|
|
'x:OAuthClient/query',
|
|
{ accountId, filter: { clientId: CLIENT_ID } },
|
|
'0',
|
|
]],
|
|
});
|
|
|
|
const queryEntry = queryRes.methodResponses?.[0];
|
|
if (!queryEntry || queryEntry[0] === 'error') {
|
|
return NextResponse.json({
|
|
error: 'Stalwart denied OAuthClient/query - your Stalwart account likely lacks admin permissions.',
|
|
detail: queryEntry?.[1],
|
|
}, { status: 403 });
|
|
}
|
|
const existingIds = (queryEntry[1].ids as string[] | undefined) ?? [];
|
|
|
|
const secret = randomBytes(32).toString('base64url');
|
|
const redirectUris = buildRedirectUris(origin, localeList);
|
|
|
|
const action: 'created' | 'updated' = existingIds.length > 0 ? 'updated' : 'created';
|
|
// Stalwart 1.0 gives a client full access to the account (its Stalwart
|
|
// settings too: password, TOTP, app passwords) only when it is first-party;
|
|
// any other client gets just the data scopes it was granted. 0.16 has no
|
|
// such field and refuses it as an invalid patch, so the setup retries
|
|
// without it there.
|
|
const buildSetArgs = (firstParty: boolean): Record<string, unknown> => {
|
|
const extra = firstParty ? { firstParty: true } : {};
|
|
return action === 'updated'
|
|
? {
|
|
accountId,
|
|
update: {
|
|
[existingIds[0]]: {
|
|
secret,
|
|
redirectUris,
|
|
description: CLIENT_DESCRIPTION,
|
|
...extra,
|
|
},
|
|
},
|
|
}
|
|
: {
|
|
accountId,
|
|
create: {
|
|
new: {
|
|
clientId: CLIENT_ID,
|
|
description: CLIENT_DESCRIPTION,
|
|
secret,
|
|
redirectUris,
|
|
contacts: { [creds.username]: true },
|
|
...extra,
|
|
},
|
|
},
|
|
};
|
|
};
|
|
const callSet = (firstParty: boolean) => jmapCall(creds, {
|
|
using: STALWART_USING,
|
|
methodCalls: [['x:OAuthClient/set', buildSetArgs(firstParty), '0']],
|
|
});
|
|
|
|
let firstParty = true;
|
|
let setRes = await callSet(true);
|
|
if (refusedFirstParty(setRes)) {
|
|
firstParty = false;
|
|
setRes = await callSet(false);
|
|
}
|
|
|
|
const setEntry = setRes.methodResponses?.[0];
|
|
if (!setEntry || setEntry[0] === 'error') {
|
|
return NextResponse.json({
|
|
error: 'Stalwart denied OAuthClient/set - admin permissions required.',
|
|
detail: setEntry?.[1],
|
|
}, { status: 403 });
|
|
}
|
|
const setBody = setEntry[1] as {
|
|
created?: Record<string, { id?: string }>;
|
|
notCreated?: Record<string, unknown>;
|
|
notUpdated?: Record<string, unknown>;
|
|
};
|
|
if (setBody.notCreated && Object.keys(setBody.notCreated).length > 0) {
|
|
return NextResponse.json(
|
|
{ error: 'Stalwart refused to create the OAuth client.', detail: setBody.notCreated },
|
|
{ status: 502 },
|
|
);
|
|
}
|
|
if (setBody.notUpdated && Object.keys(setBody.notUpdated).length > 0) {
|
|
return NextResponse.json(
|
|
{ error: 'Stalwart refused to update the OAuth client.', detail: setBody.notUpdated },
|
|
{ status: 502 },
|
|
);
|
|
}
|
|
|
|
await configManager.ensureLoaded();
|
|
const updates: Record<string, unknown> = {
|
|
oauthEnabled: true,
|
|
oauthClientId: CLIENT_ID,
|
|
oauthClientSecret: secret,
|
|
oauthIssuerUrl: issuerUrl,
|
|
};
|
|
if (oauthOnly) updates.oauthOnly = true;
|
|
try {
|
|
await configManager.setAdminConfig(updates);
|
|
} catch (error) {
|
|
// Don't leave a client behind that this install never stored. An
|
|
// updated client can't be restored: Stalwart doesn't hand back the
|
|
// old secret.
|
|
if (action === 'created') {
|
|
const createdId = setBody.created?.new?.id;
|
|
if (createdId) {
|
|
await jmapCall(creds, {
|
|
using: STALWART_USING,
|
|
methodCalls: [['x:OAuthClient/set', { accountId, destroy: [createdId] }, '0']],
|
|
}).catch((rollbackError) => {
|
|
logger.error('Admin OAuth setup rollback failed', {
|
|
error: rollbackError instanceof Error ? rollbackError.message : 'Unknown error',
|
|
});
|
|
});
|
|
}
|
|
}
|
|
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,
|
|
origin,
|
|
issuer: issuerUrl,
|
|
redirectUriCount: localeList.length,
|
|
oauthOnly,
|
|
mobileClient,
|
|
}, ip);
|
|
|
|
logger.info('Admin OAuth setup', {
|
|
action,
|
|
clientId: CLIENT_ID,
|
|
origin,
|
|
issuer: issuerUrl,
|
|
locales: localeList.length,
|
|
mobileClient,
|
|
});
|
|
|
|
return NextResponse.json({
|
|
ok: true,
|
|
action,
|
|
clientId: CLIENT_ID,
|
|
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' });
|
|
return NextResponse.json(
|
|
{ error: error instanceof Error ? error.message : 'Internal server error' },
|
|
{ status: 500 },
|
|
);
|
|
}
|
|
}
|