diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index 953195f4..563fbefe 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -14,7 +14,7 @@ import { useConfig } from "@/hooks/use-config"; import { apiFetch, getPathPrefix, withBasePath } from "@/lib/browser-navigation"; import { cn } from "@/lib/utils"; import { AlertCircle, Loader2, X, Info, Eye, EyeOff, LogIn, Sun, Moon, Monitor, Check, Shield, Play, Copy } from "lucide-react"; -import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery"; +import { type OAuthMetadata } from "@/lib/oauth/discovery"; import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce"; import { useUpdateStore, selectBanner } from "@/stores/update-store"; import type { PublicJmapServerEntry } from "@/lib/admin/jmap-servers"; @@ -329,16 +329,27 @@ export default function LoginPage() { if (!oauthEnabled || !serverUrl) return; setOauthDiscoveryDone(false); setOauthMetadata(null); - discoverOAuth(effectiveOauthIssuerUrl || serverUrl) + const controller = new AbortController(); + // Discover via our own origin rather than fetching the IdP's /.well-known/* + // documents directly from the browser. A direct cross-origin discovery + // fetch is subject to CORS, and providers like Authentik serve those + // documents without Access-Control-Allow-Origin, so the browser blocks the + // response and login breaks (issue #382). The proxy runs discovery server + // side where CORS does not apply. + const query = selectedServer?.id ? `?server_id=${encodeURIComponent(selectedServer.id)}` : ""; + apiFetch(`/api/auth/oauth/metadata${query}`, { signal: controller.signal }) + .then(async (res) => (res.ok ? ((await res.json()) as OAuthMetadata) : null)) .then((metadata) => { setOauthMetadata(metadata); setOauthDiscoveryDone(true); }) - .catch(() => { + .catch((err) => { + if (err?.name === "AbortError") return; setOauthMetadata(null); setOauthDiscoveryDone(true); }); - }, [oauthEnabled, serverUrl, effectiveOauthIssuerUrl]); + return () => controller.abort(); + }, [oauthEnabled, serverUrl, effectiveOauthIssuerUrl, selectedServer?.id]); // Auto-SSO: when enabled with OAUTH_ONLY, skip the login page entirely const ssoError = searchParams.get("sso_error"); diff --git a/app/api/auth/oauth/metadata/route.ts b/app/api/auth/oauth/metadata/route.ts new file mode 100644 index 00000000..a720a18a --- /dev/null +++ b/app/api/auth/oauth/metadata/route.ts @@ -0,0 +1,53 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { logger } from '@/lib/logger'; +import { configManager } from '@/lib/admin/config-manager'; +import { getMetadata, getRequiredConfig } from '@/lib/oauth/token-exchange'; + +/** + * Same-origin OAuth metadata (discovery) proxy. + * + * The login page needs the authorization_endpoint to build the PKCE authorize + * URL in the browser. Discovering it directly from the browser means a + * cross-origin fetch to the IdP's /.well-known/* documents, which is subject + * to CORS: providers like Authentik serve those documents without an + * Access-Control-Allow-Origin header, so the browser blocks the response and + * discovery fails (issue #382). Performing discovery here - server to server, + * where CORS does not apply - and handing the result back as a same-origin + * response sidesteps the problem entirely. + * + * The discovery URL is resolved from admin config (via server_id), never from + * client input, so this cannot be abused as an open SSRF proxy. Endpoint URLs + * in the discovered document are still gated by the SSRF validator inside + * discoverOAuth. The returned fields are public well-known metadata. + */ +export async function GET(request: NextRequest) { + await configManager.ensureLoaded(); + const serverId = request.nextUrl.searchParams.get('server_id'); + + let discoveryUrl: string; + try { + ({ discoveryUrl } = getRequiredConfig(serverId)); + } catch { + // OAuth not configured for this server - surface as "no metadata" rather + // than a 500 so the login page just hides the SSO button. + return NextResponse.json({ error: 'OAuth not configured' }, { status: 404 }); + } + + try { + const metadata = await getMetadata(serverId); + if (!metadata?.authorization_endpoint || !metadata.token_endpoint) { + logger.warn('OAuth metadata discovery returned no usable endpoints', { discoveryUrl }); + return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 }); + } + return NextResponse.json(metadata, { + // Mirror the in-process discovery cache TTL so repeated login-page loads + // hit the CDN/browser cache instead of re-running discovery. + headers: { 'Cache-Control': 'private, max-age=600' }, + }); + } catch (error) { + logger.error('OAuth metadata discovery error', { + error: error instanceof Error ? error.message : 'Unknown error', + }); + return NextResponse.json({ error: 'OAuth discovery failed' }, { status: 502 }); + } +} diff --git a/lib/__tests__/oauth-metadata-route.test.ts b/lib/__tests__/oauth-metadata-route.test.ts new file mode 100644 index 00000000..04540209 --- /dev/null +++ b/lib/__tests__/oauth-metadata-route.test.ts @@ -0,0 +1,101 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { OAuthMetadata } from '../oauth/discovery'; + +// Capture status/headers from NextResponse.json (the shared config-route mock +// drops them, but this route's behavior depends on the status code). +vi.mock('next/server', () => ({ + NextResponse: { + json: (data: unknown, init?: { status?: number; headers?: Record }) => ({ + json: async () => data, + status: init?.status ?? 200, + headers: init?.headers ?? {}, + }), + }, +})); + +vi.mock('@/lib/logger', () => ({ + logger: { warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + +vi.mock('@/lib/admin/config-manager', () => ({ + configManager: { ensureLoaded: vi.fn().mockResolvedValue(undefined) }, +})); + +const getMetadata = vi.fn(); +const getRequiredConfig = vi.fn(); +vi.mock('@/lib/oauth/token-exchange', () => ({ + getMetadata: (...args: unknown[]) => getMetadata(...args), + getRequiredConfig: (...args: unknown[]) => getRequiredConfig(...args), +})); + +const VALID_METADATA: OAuthMetadata = { + issuer: 'https://auth.example.com', + authorization_endpoint: 'https://auth.example.com/authorize', + token_endpoint: 'https://auth.example.com/token', +}; + +function mockRequest(serverId?: string): unknown { + return { + nextUrl: { + searchParams: { get: (k: string) => (k === 'server_id' ? serverId ?? null : null) }, + }, + }; +} + +async function callRoute(serverId?: string) { + const { GET } = await import('@/app/api/auth/oauth/metadata/route'); + const res = (await GET(mockRequest(serverId) as Parameters[0])) as unknown as { + status: number; + headers: Record; + json: () => Promise>; + }; + return { status: res.status, headers: res.headers, body: await res.json() }; +} + +describe('oauth metadata route', () => { + beforeEach(() => { + vi.clearAllMocks(); + getRequiredConfig.mockReturnValue({ discoveryUrl: 'https://auth.example.com' }); + }); + + it('returns discovered metadata for the resolved server', async () => { + getMetadata.mockResolvedValue(VALID_METADATA); + + const { status, body, headers } = await callRoute('server-1'); + + expect(status).toBe(200); + expect(body).toEqual(VALID_METADATA); + expect(headers['Cache-Control']).toContain('max-age=600'); + expect(getMetadata).toHaveBeenCalledWith('server-1'); + }); + + it('returns 404 when OAuth is not configured', async () => { + getRequiredConfig.mockImplementation(() => { + throw new Error('OAuth misconfigured: OAUTH_CLIENT_ID not set'); + }); + + const { status, body } = await callRoute(); + + expect(status).toBe(404); + expect(body).toEqual({ error: 'OAuth not configured' }); + expect(getMetadata).not.toHaveBeenCalled(); + }); + + it('returns 502 when discovery yields no usable endpoints', async () => { + getMetadata.mockResolvedValue(null); + + const { status, body } = await callRoute(); + + expect(status).toBe(502); + expect(body).toEqual({ error: 'OAuth discovery failed' }); + }); + + it('returns 502 when discovery throws', async () => { + getMetadata.mockRejectedValue(new Error('network down')); + + const { status, body } = await callRoute(); + + expect(status).toBe(502); + expect(body).toEqual({ error: 'OAuth discovery failed' }); + }); +});