fix: tell admins the OAuth setup needs a webmail sign-in and disable it until there is one

This commit is contained in:
Linus Rath
2026-10-28 17:40:26 +01:00
parent 90ec6748ac
commit d10d6b60ce
3 changed files with 78 additions and 12 deletions
+24 -1
View File
@@ -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."
>
<div className="flex flex-col items-start gap-1 sm:items-end">
<Button type="button" size="sm" className="gap-2" onClick={openSetupDialog} disabled={setupRunning || configReadOnly}>
<Button type="button" size="sm" className="gap-2" onClick={openSetupDialog} disabled={setupRunning || configReadOnly || setupSignIn?.signedIn === false}>
{setupRunning ? <Loader2 className="w-4 h-4 animate-spin" /> : <Sparkles className="w-4 h-4" />}
{setupRunning ? 'Configuring…' : 'Set up automagically'}
</Button>
@@ -269,6 +282,16 @@ export function AuthPage() {
Not available while the configuration is read-only (ADMIN_CONFIG_READONLY).
</p>
)}
{!configReadOnly && setupSignIn?.signedIn === false && (
<p className="max-w-xs text-xs text-muted-foreground sm:text-end">
Only works while you are signed in to{' '}
<a href="/" target="_blank" rel="noopener" className="text-primary hover:underline">this webmail</a>
{' '}with a Stalwart admin account in this browser. A login to the Stalwart web admin does not count.
</p>
)}
{!configReadOnly && setupSignIn?.signedIn && setupSignIn.username && (
<p className="text-xs text-muted-foreground">Runs as {setupSignIn.username}</p>
)}
</div>
</SettingItem>
<ConfigToggleItem label="Enabled" configKey="oauthEnabled" value={currentValue('oauthEnabled') as boolean} source={config.oauthEnabled?.source} onChange={handleChange} onRevert={handleRevert} />
@@ -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<string, unknown>]> = [];
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<typeof import('@/lib/admin/paths')>()),
@@ -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');
});
});
+15 -4
View File
@@ -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;