feat: multi-server JMAP support

This commit is contained in:
Linus Rath
2026-05-06 17:33:55 +02:00
parent 43475945bf
commit d3d79be64c
20 changed files with 818 additions and 108 deletions
+36 -15
View File
@@ -3,17 +3,31 @@ import { discoverOAuth } from '@/lib/oauth/discovery';
import type { OAuthMetadata } from '@/lib/oauth/discovery';
import { readFileEnv } from '@/lib/read-file-env';
import { configManager } from '@/lib/admin/config-manager';
import { parseJmapServers, findServerById } from '@/lib/admin/jmap-servers';
function getClientSecret(): string {
function getGlobalClientSecret(): string {
const adminSecret = configManager.get<string>('oauthClientSecret', '');
if (adminSecret) return adminSecret;
return process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE) || '';
}
export function getRequiredConfig() {
const clientId = configManager.get<string>('oauthClientId', '') || process.env.OAUTH_CLIENT_ID;
const serverUrl = configManager.get<string>('jmapServerUrl', '') || process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL;
const issuerUrl = configManager.get<string>('oauthIssuerUrl', '') || process.env.OAUTH_ISSUER_URL;
function getServerEntry(serverId?: string | null) {
if (!serverId) return undefined;
const servers = parseJmapServers(configManager.get<unknown>('jmapServers', []));
return findServerById(servers, serverId);
}
export function getRequiredConfig(serverId?: string | null) {
const entry = getServerEntry(serverId);
const globalClientId = configManager.get<string>('oauthClientId', '') || process.env.OAUTH_CLIENT_ID;
const globalServerUrl = configManager.get<string>('jmapServerUrl', '') || process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL;
const globalIssuerUrl = configManager.get<string>('oauthIssuerUrl', '') || process.env.OAUTH_ISSUER_URL;
const clientId = entry?.oauth?.clientId || globalClientId;
const serverUrl = entry?.url || globalServerUrl;
const issuerUrl = entry?.oauth?.issuerUrl || globalIssuerUrl;
if (!clientId || !serverUrl) {
throw new Error(`OAuth misconfigured: ${[!clientId && 'OAUTH_CLIENT_ID', !serverUrl && 'JMAP_SERVER_URL'].filter(Boolean).join(', ')} not set`);
}
@@ -21,11 +35,17 @@ export function getRequiredConfig() {
if (issuerUrl !== undefined && issuerUrl !== '' && !issuerUrl.trim()) {
logger.warn('OAUTH_ISSUER_URL is set but empty, falling back to JMAP_SERVER_URL for discovery');
}
return { clientId, serverUrl, discoveryUrl };
return { clientId, serverUrl, discoveryUrl, serverId: entry?.id };
}
export async function getTokenEndpoint(): Promise<string> {
const { discoveryUrl } = getRequiredConfig();
function getClientSecret(serverId?: string | null): string {
const entry = getServerEntry(serverId);
if (entry?.oauth?.clientSecret) return entry.oauth.clientSecret;
return getGlobalClientSecret();
}
export async function getTokenEndpoint(serverId?: string | null): Promise<string> {
const { discoveryUrl } = getRequiredConfig(serverId);
const metadata = await discoverOAuth(discoveryUrl);
if (!metadata?.token_endpoint) {
throw new Error('OAuth token endpoint not found');
@@ -33,15 +53,15 @@ export async function getTokenEndpoint(): Promise<string> {
return metadata.token_endpoint;
}
export async function getMetadata(): Promise<OAuthMetadata | null> {
const { discoveryUrl } = getRequiredConfig();
export async function getMetadata(serverId?: string | null): Promise<OAuthMetadata | null> {
const { discoveryUrl } = getRequiredConfig(serverId);
return discoverOAuth(discoveryUrl);
}
export function buildOAuthParams(base: Record<string, string>): URLSearchParams {
const { clientId } = getRequiredConfig();
export function buildOAuthParams(base: Record<string, string>, serverId?: string | null): URLSearchParams {
const { clientId } = getRequiredConfig(serverId);
const params = new URLSearchParams({ ...base, client_id: clientId });
const secret = getClientSecret();
const secret = getClientSecret(serverId);
if (secret) {
params.set('client_secret', secret);
}
@@ -58,15 +78,16 @@ export async function exchangeCodeForTokens(
code: string,
codeVerifier: string,
redirectUri: string,
serverId?: string | null,
): Promise<TokenResult> {
const tokenEndpoint = await getTokenEndpoint();
const tokenEndpoint = await getTokenEndpoint(serverId);
const params = buildOAuthParams({
grant_type: 'authorization_code',
code,
redirect_uri: redirectUri,
code_verifier: codeVerifier,
});
}, serverId);
const tokenResponse = await fetch(tokenEndpoint, {
method: 'POST',