diff --git a/app/(main)/admin/_tabs/auth.tsx b/app/(main)/admin/_tabs/auth.tsx
index 4387ef2e..5bda49b3 100644
--- a/app/(main)/admin/_tabs/auth.tsx
+++ b/app/(main)/admin/_tabs/auth.tsx
@@ -273,6 +273,7 @@ export function AuthTab() {
+
diff --git a/app/api/auth/sso/start/route.ts b/app/api/auth/sso/start/route.ts
index fd0e861f..9edfbd31 100644
--- a/app/api/auth/sso/start/route.ts
+++ b/app/api/auth/sso/start/route.ts
@@ -3,9 +3,8 @@ import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { encryptPayload } from '@/lib/auth/crypto';
import { generateCodeVerifierServer, generateCodeChallengeServer, generateStateServer } from '@/lib/oauth/pkce-server';
-import { getRequiredConfig } from '@/lib/oauth/token-exchange';
+import { getRequiredConfig, getDiscoveryValidator } from '@/lib/oauth/token-exchange';
import { discoverOAuth } from '@/lib/oauth/discovery';
-import { isPublicHttpUrl } from '@/lib/security/url-guard';
import { getOauthScopes } from '@/lib/oauth/tokens';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { hasSessionSecret } from '@/lib/auth/session-secret';
@@ -62,7 +61,7 @@ export async function POST(request: NextRequest) {
}
const { clientId, discoveryUrl } = getRequiredConfig(serverId);
- const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: isPublicHttpUrl });
+ const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: getDiscoveryValidator() });
if (!metadata?.authorization_endpoint) {
return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 });
diff --git a/app/api/auth/totp-token-exchange/route.ts b/app/api/auth/totp-token-exchange/route.ts
index e19bc781..11fa1a6b 100644
--- a/app/api/auth/totp-token-exchange/route.ts
+++ b/app/api/auth/totp-token-exchange/route.ts
@@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { discoverOAuth } from '@/lib/oauth/discovery';
+import { getDiscoveryValidator } from '@/lib/oauth/token-exchange';
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
import { getCookieOptions } from '@/lib/oauth/cookie-config';
import { readFileEnv } from '@/lib/read-file-env';
@@ -52,9 +53,13 @@ async function tryTokenRequest(
}
}
-async function findTokenEndpoint(serverUrl: string): Promise {
+async function findTokenEndpoint(serverUrl: string, adminTrusted: boolean): Promise {
+ // Admin-trusted callers (matched server entry or configured JMAP server URL)
+ // honor the `oauthAllowPrivateEndpoints` opt-in. User-supplied URLs always
+ // go through the SSRF validator regardless of the setting.
+ const validateEndpoint = adminTrusted ? getDiscoveryValidator() : isPublicHttpUrl;
// 1. Try OAuth discovery
- const metadata = await discoverOAuth(serverUrl, { validateEndpoint: isPublicHttpUrl });
+ const metadata = await discoverOAuth(serverUrl, { validateEndpoint });
if (metadata?.token_endpoint) return metadata.token_endpoint;
// 2. Try common Stalwart token endpoint paths directly
@@ -105,14 +110,17 @@ export async function POST(request: NextRequest) {
let upstreamUrl: string;
let resolvedServerId: string | null = null;
+ let adminTrusted = false;
const requestedEntry = findServerById(serverList, requestedServerId);
const matchedEntry = requestedEntry || findServerByUrl(serverList, serverUrl);
if (matchedEntry) {
upstreamUrl = matchedEntry.url;
resolvedServerId = matchedEntry.id;
+ adminTrusted = true;
} else if (configuredServerUrl) {
upstreamUrl = configuredServerUrl;
+ adminTrusted = true;
} else if (allowCustomEndpoint) {
if (!(await isPublicHttpUrl(serverUrl))) {
logger.warn('TOTP token exchange: rejected non-public server URL');
@@ -123,7 +131,7 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: 'jmap_server_not_configured' }, { status: 500 });
}
- const tokenEndpoint = await findTokenEndpoint(upstreamUrl);
+ const tokenEndpoint = await findTokenEndpoint(upstreamUrl, adminTrusted);
if (!tokenEndpoint) {
logger.warn('TOTP token exchange: no token endpoint found');
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 });
diff --git a/lib/__tests__/oauth-discovery.test.ts b/lib/__tests__/oauth-discovery.test.ts
index 609a9294..5b8228af 100644
--- a/lib/__tests__/oauth-discovery.test.ts
+++ b/lib/__tests__/oauth-discovery.test.ts
@@ -146,6 +146,23 @@ describe('oauth/discovery', () => {
expect(consoleSpy).toHaveBeenCalled();
});
+ it('accepts private/loopback endpoints when validateEndpoint is omitted (admin opted in)', async () => {
+ // Split-DNS deployments: mail.example.com resolves to an RFC-1918 address
+ // locally. With the SSRF validator off, discovery must succeed.
+ vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({
+ ok: true,
+ json: () => Promise.resolve({
+ issuer: 'https://mail.example.com',
+ authorization_endpoint: 'http://10.0.0.5/authorize',
+ token_endpoint: 'http://10.0.0.5/token',
+ }),
+ }));
+
+ const result = await discoverOAuth('https://mail.example.com');
+
+ expect(result?.token_endpoint).toBe('http://10.0.0.5/token');
+ });
+
it('caches results - second call for same server URL does not re-fetch', async () => {
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce({
ok: true,
diff --git a/lib/admin/types.ts b/lib/admin/types.ts
index 320c783b..57e8535b 100644
--- a/lib/admin/types.ts
+++ b/lib/admin/types.ts
@@ -153,6 +153,7 @@ export const CONFIG_ENV_MAP: Record('oauthAllowPrivateEndpoints', false);
+ return allowPrivate ? undefined : isPublicHttpUrl;
+}
+
function getGlobalClientSecret(): string {
const adminSecret = configManager.get('oauthClientSecret', '');
if (adminSecret) return adminSecret;
@@ -47,7 +57,7 @@ function getClientSecret(serverId?: string | null): string {
export async function getTokenEndpoint(serverId?: string | null): Promise {
const { discoveryUrl } = getRequiredConfig(serverId);
- const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: isPublicHttpUrl });
+ const metadata = await discoverOAuth(discoveryUrl, { validateEndpoint: getDiscoveryValidator() });
if (!metadata?.token_endpoint) {
throw new Error('OAuth token endpoint not found');
}
@@ -56,7 +66,7 @@ export async function getTokenEndpoint(serverId?: string | null): Promise {
const { discoveryUrl } = getRequiredConfig(serverId);
- return discoverOAuth(discoveryUrl, { validateEndpoint: isPublicHttpUrl });
+ return discoverOAuth(discoveryUrl, { validateEndpoint: getDiscoveryValidator() });
}
export function buildOAuthParams(base: Record, serverId?: string | null): URLSearchParams {