mirror of
https://github.com/bulwarkmail/webmail.git
synced 2026-11-03 00:13:09 +00:00
Config, policy, branding, OAuth setup, password and setup wizard writes used to answer 500 when the shared store couldn't be reached, and the wizard's token check failed outside any handler. They answer 503 with Retry-After now, through the helper that already turned a read-only refusal into 409. The step-session-secret test's wizard mock gains wizardSessionRefusal, which the setup routes now call.
397 lines
14 KiB
TypeScript
397 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 { configWriteErrorResponse } from '@/lib/admin/config-read-only-response';
|
|
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) {
|
|
const refused = configWriteErrorResponse(error);
|
|
if (refused) return refused;
|
|
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 },
|
|
);
|
|
}
|
|
}
|