fix: discover OIDC metadata server-side to avoid CORS failures #382

This commit is contained in:
Linus Rath
2026-06-02 00:15:50 +02:00
parent 152ec99262
commit 31100b8f87
3 changed files with 169 additions and 4 deletions
+15 -4
View File
@@ -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");
+53
View File
@@ -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 });
}
}
+101
View File
@@ -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<string, string> }) => ({
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<typeof GET>[0])) as unknown as {
status: number;
headers: Record<string, string>;
json: () => Promise<Record<string, unknown>>;
};
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' });
});
});