From d10d6b60ce4aea66c0f8e1fcf200ce50ee1e01c2 Mon Sep 17 00:00:00 2001
From: Linus Rath <139418639+rathlinus@users.noreply.github.com>
Date: Wed, 28 Oct 2026 17:40:26 +0100
Subject: [PATCH] fix: tell admins the OAuth setup needs a webmail sign-in and
disable it until there is one
---
app/(main)/admin/_pages/auth.tsx | 25 +++++++++-
.../admin/oauth/setup/__tests__/route.test.ts | 46 ++++++++++++++++---
app/api/admin/oauth/setup/route.ts | 19 ++++++--
3 files changed, 78 insertions(+), 12 deletions(-)
diff --git a/app/(main)/admin/_pages/auth.tsx b/app/(main)/admin/_pages/auth.tsx
index 1d1349092..04afb2e38 100644
--- a/app/(main)/admin/_pages/auth.tsx
+++ b/app/(main)/admin/_pages/auth.tsx
@@ -34,12 +34,25 @@ export function AuthPage() {
const setMessage = useAdminFeedback();
const [configReadOnly, setConfigReadOnly] = useState(false);
+ // The setup runs as the mail account signed in to this webmail. Asked again
+ // on focus, since the admin signs in from another tab and comes back.
+ const [setupSignIn, setSetupSignIn] = useState<{ signedIn: boolean; username?: string } | null>(null);
+
useEffect(() => {
fetchConfig();
apiFetch('/api/setup/status')
.then(res => (res.ok ? res.json() : null))
.then(data => setConfigReadOnly(data?.readOnly === true))
.catch(() => {});
+ const checkSignIn = () => {
+ apiFetch('/api/admin/oauth/setup')
+ .then(res => (res.ok ? res.json() : null))
+ .then(data => { if (data) setSetupSignIn(data); })
+ .catch(() => {});
+ };
+ checkSignIn();
+ window.addEventListener('focus', checkSignIn);
+ return () => window.removeEventListener('focus', checkSignIn);
}, []);
async function fetchConfig() {
@@ -260,7 +273,7 @@ export function AuthPage() {
description="Registers an OAuth client on the connected Stalwart server, generates a client secret, and saves the settings here. Requires your Stalwart account to have admin permissions."
>
-
diff --git a/app/api/admin/oauth/setup/__tests__/route.test.ts b/app/api/admin/oauth/setup/__tests__/route.test.ts
index 7046502d1..c3a416fb1 100644
--- a/app/api/admin/oauth/setup/__tests__/route.test.ts
+++ b/app/api/admin/oauth/setup/__tests__/route.test.ts
@@ -7,6 +7,7 @@ import { NextRequest } from 'next/server';
let readOnly = false;
let saveError: Error | null = null;
let stalwart016 = false;
+let signedIn = true;
const jmapCalls: Array<[string, Record]> = [];
vi.mock('@/lib/admin/session', () => ({
@@ -14,12 +15,14 @@ vi.mock('@/lib/admin/session', () => ({
getClientIP: () => '127.0.0.1',
}));
vi.mock('@/lib/stalwart/credentials', () => ({
- getStalwartCredentials: async () => ({
- serverUrl: 'https://mail.example.org',
- authHeader: 'Basic x',
- trusted: true,
- username: 'admin@example.org',
- }),
+ getStalwartCredentials: async () => (signedIn
+ ? {
+ serverUrl: 'https://mail.example.org',
+ authHeader: 'Basic x',
+ trusted: true,
+ username: 'admin@example.org',
+ }
+ : null),
}));
vi.mock('@/lib/admin/paths', async (importOriginal) => ({
...(await importOriginal()),
@@ -49,7 +52,7 @@ vi.mock('@/lib/stalwart/server-fetch', () => ({
},
}));
-import { POST } from '../route';
+import { GET, POST } from '../route';
function setup() {
return POST(new NextRequest('http://localhost/api/admin/oauth/setup', {
@@ -62,6 +65,7 @@ beforeEach(() => {
readOnly = false;
saveError = null;
stalwart016 = false;
+ signedIn = true;
jmapCalls.length = 0;
});
@@ -89,6 +93,14 @@ describe('POST /api/admin/oauth/setup', () => {
expect(create).not.toHaveProperty('firstParty');
});
+ it('tells the admin to sign in to the webmail when no mail account is signed in here', async () => {
+ signedIn = false;
+ const res = await setup();
+ expect(res.status).toBe(400);
+ expect((await res.json()).error).toMatch(/Sign in to this webmail.*Stalwart web admin does not count/);
+ expect(jmapCalls).toEqual([]);
+ });
+
it('refuses in read-only mode without sending anything to Stalwart (#1250)', async () => {
readOnly = true;
const res = await setup();
@@ -104,3 +116,23 @@ describe('POST /api/admin/oauth/setup', () => {
expect(jmapCalls.at(-1)).toEqual(['x:OAuthClient/set', { accountId: 'a', destroy: ['h'] }]);
});
});
+
+describe('GET /api/admin/oauth/setup', () => {
+ const check = () => GET(new NextRequest('http://localhost/api/admin/oauth/setup'));
+
+ it('names the mail account the setup would run as', async () => {
+ const res = await check();
+ expect(await res.json()).toEqual({ signedIn: true, username: 'admin@example.org' });
+ });
+
+ it('says so when no mail account is signed in to this webmail', async () => {
+ signedIn = false;
+ const res = await check();
+ expect(await res.json()).toEqual({ signedIn: false });
+ });
+
+ it('never hands the credential to the page', async () => {
+ const body = JSON.stringify(await (await check()).json());
+ expect(body).not.toContain('Basic');
+ });
+});
diff --git a/app/api/admin/oauth/setup/route.ts b/app/api/admin/oauth/setup/route.ts
index 6727366ad..074b5cfdf 100644
--- a/app/api/admin/oauth/setup/route.ts
+++ b/app/api/admin/oauth/setup/route.ts
@@ -102,6 +102,20 @@ 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);
@@ -120,10 +134,7 @@ export async function POST(request: NextRequest) {
const ip = getClientIP(request);
const creds = await getStalwartCredentials(request);
if (!creds) {
- return NextResponse.json(
- { error: 'No Stalwart session available. Sign in to your mail account in another tab and retry.' },
- { status: 400 },
- );
+ return NextResponse.json({ error: NOT_SIGNED_IN }, { status: 400 });
}
const body = await request.json() as SetupRequestBody;