feat: multi-server JMAP support
This commit is contained in:
@@ -43,6 +43,7 @@ function OAuthCallbackInner() {
|
||||
|
||||
const codeVerifier = sessionStorage.getItem("oauth_code_verifier");
|
||||
const serverUrl = sessionStorage.getItem("oauth_server_url");
|
||||
const serverId = sessionStorage.getItem("oauth_server_id") || undefined;
|
||||
|
||||
if (!codeVerifier || !serverUrl) {
|
||||
setError("missing_params");
|
||||
@@ -52,12 +53,13 @@ function OAuthCallbackInner() {
|
||||
const prefix = getPathPrefix(params.locale as string);
|
||||
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
|
||||
|
||||
loginWithOAuth(serverUrl, code, codeVerifier, redirectUri)
|
||||
loginWithOAuth(serverUrl, code, codeVerifier, redirectUri, serverId)
|
||||
.then((success) => {
|
||||
if (success) {
|
||||
sessionStorage.removeItem("oauth_state");
|
||||
sessionStorage.removeItem("oauth_code_verifier");
|
||||
sessionStorage.removeItem("oauth_server_url");
|
||||
sessionStorage.removeItem("oauth_server_id");
|
||||
sessionStorage.removeItem("oauth_add_account_mode");
|
||||
let redirectTo = `${prefix}/${params.locale}`;
|
||||
try {
|
||||
|
||||
+150
-46
@@ -18,6 +18,14 @@ import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
||||
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
||||
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
|
||||
import { useUpdateStore, selectBanner } from "@/stores/update-store";
|
||||
import type { PublicJmapServerEntry } from "@/lib/admin/jmap-servers";
|
||||
|
||||
function findServerByDomain(servers: PublicJmapServerEntry[], email: string | undefined): PublicJmapServerEntry | undefined {
|
||||
if (!email || !email.includes("@")) return undefined;
|
||||
const domain = email.split("@")[1]?.trim().toLowerCase();
|
||||
if (!domain) return undefined;
|
||||
return servers.find((s) => (s.domains ?? []).some((d) => d.toLowerCase() === domain));
|
||||
}
|
||||
|
||||
const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
|
||||
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
|
||||
@@ -109,7 +117,7 @@ export default function LoginPage() {
|
||||
const isAddAccountMode = searchParams.get("mode") === "add-account";
|
||||
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
||||
const { theme, setTheme, initializeTheme } = useThemeStore(useShallow((s) => ({ theme: s.theme, setTheme: s.setTheme, initializeTheme: s.initializeTheme })));
|
||||
const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthOnly, oauthClientId, oauthIssuerUrl, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint } = useConfig();
|
||||
const { appName, jmapServerUrl: configuredServerUrl, oauthEnabled, oauthOnly, oauthClientId: globalOauthClientId, oauthIssuerUrl: globalOauthIssuerUrl, rememberMeEnabled, devMode, demoMode, loginLogoLightUrl, loginLogoDarkUrl, loginCompanyName, loginImprintUrl, loginPrivacyPolicyUrl, loginWebsiteUrl, isLoading: configLoading, error: configError, autoSsoEnabled, embeddedMode: _embeddedMode, allowCustomJmapEndpoint, jmapServers, jmapServerAutoPickByDomain } = useConfig();
|
||||
const resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
@@ -117,6 +125,18 @@ export default function LoginPage() {
|
||||
password: "",
|
||||
});
|
||||
const [jmapEndpoint, setJmapEndpoint] = useState("");
|
||||
const [selectedServerId, setSelectedServerId] = useState<string | null>(null);
|
||||
const [domainAutoLocked, setDomainAutoLocked] = useState(false);
|
||||
|
||||
const hasServerList = jmapServers.length > 0;
|
||||
const selectedServer = hasServerList
|
||||
? jmapServers.find((s) => s.id === selectedServerId) ?? jmapServers[0]
|
||||
: undefined;
|
||||
|
||||
// Effective values: per-server overrides win, then global config.
|
||||
const serverUrl = selectedServer?.url || configuredServerUrl;
|
||||
const effectiveOauthClientId = selectedServer?.oauth?.clientId || globalOauthClientId;
|
||||
const effectiveOauthIssuerUrl = selectedServer?.oauth?.issuerUrl || globalOauthIssuerUrl;
|
||||
const [totpCode, setTotpCode] = useState("");
|
||||
const [showTotpField, setShowTotpField] = useState(false);
|
||||
const [rememberMe, setRememberMe] = useState(false);
|
||||
@@ -157,6 +177,27 @@ export default function LoginPage() {
|
||||
}
|
||||
}, [serverUrl, jmapEndpoint]);
|
||||
|
||||
// Initialize selected server when the server list arrives. Picks the first
|
||||
// entry; the auto-pick effect below may override based on the email domain.
|
||||
useEffect(() => {
|
||||
if (!hasServerList) return;
|
||||
if (selectedServerId && jmapServers.some((s) => s.id === selectedServerId)) return;
|
||||
setSelectedServerId(jmapServers[0].id);
|
||||
}, [hasServerList, jmapServers, selectedServerId]);
|
||||
|
||||
// Auto-pick by email domain. Locks the dropdown to the matched server until
|
||||
// the user clears the email or types a domain we don't recognize.
|
||||
useEffect(() => {
|
||||
if (!jmapServerAutoPickByDomain || !hasServerList) return;
|
||||
const match = findServerByDomain(jmapServers, formData.username);
|
||||
if (match) {
|
||||
if (selectedServerId !== match.id) setSelectedServerId(match.id);
|
||||
setDomainAutoLocked(true);
|
||||
} else {
|
||||
setDomainAutoLocked(false);
|
||||
}
|
||||
}, [jmapServerAutoPickByDomain, hasServerList, jmapServers, formData.username, selectedServerId]);
|
||||
|
||||
useEffect(() => {
|
||||
try {
|
||||
if (sessionStorage.getItem('session_expired') === 'true') {
|
||||
@@ -254,7 +295,9 @@ export default function LoginPage() {
|
||||
|
||||
useEffect(() => {
|
||||
if (!oauthEnabled || !serverUrl) return;
|
||||
discoverOAuth(oauthIssuerUrl || serverUrl)
|
||||
setOauthDiscoveryDone(false);
|
||||
setOauthMetadata(null);
|
||||
discoverOAuth(effectiveOauthIssuerUrl || serverUrl)
|
||||
.then((metadata) => {
|
||||
setOauthMetadata(metadata);
|
||||
setOauthDiscoveryDone(true);
|
||||
@@ -263,7 +306,7 @@ export default function LoginPage() {
|
||||
setOauthMetadata(null);
|
||||
setOauthDiscoveryDone(true);
|
||||
});
|
||||
}, [oauthEnabled, serverUrl, oauthIssuerUrl]);
|
||||
}, [oauthEnabled, serverUrl, effectiveOauthIssuerUrl]);
|
||||
|
||||
// Auto-SSO: when enabled with OAUTH_ONLY, skip the login page entirely
|
||||
const ssoError = searchParams.get("sso_error");
|
||||
@@ -278,7 +321,11 @@ export default function LoginPage() {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ redirect_uri: redirectUri, locale: params.locale }),
|
||||
body: JSON.stringify({
|
||||
redirect_uri: redirectUri,
|
||||
locale: params.locale,
|
||||
server_id: selectedServer?.id,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -304,7 +351,7 @@ export default function LoginPage() {
|
||||
} catch {
|
||||
setOauthLoading(false);
|
||||
}
|
||||
}, [params.locale]);
|
||||
}, [params.locale, selectedServer?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!autoSsoEnabled || !oauthOnly || !oauthDiscoveryDone || !oauthMetadata) return;
|
||||
@@ -445,7 +492,7 @@ export default function LoginPage() {
|
||||
};
|
||||
|
||||
const handleOAuthLogin = async () => {
|
||||
if (!oauthMetadata || !oauthClientId) return;
|
||||
if (!oauthMetadata || !effectiveOauthClientId) return;
|
||||
setOauthLoading(true);
|
||||
|
||||
const verifier = generateCodeVerifier();
|
||||
@@ -454,9 +501,19 @@ export default function LoginPage() {
|
||||
const prefix = getPathPrefix(params.locale as string);
|
||||
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
|
||||
|
||||
// Resolve the JMAP URL to send to the callback. Server-list entries win
|
||||
// over the custom-endpoint input, which wins over the global server URL.
|
||||
const oauthServerUrl = selectedServer?.url
|
||||
|| (allowCustomJmapEndpoint ? jmapEndpoint : configuredServerUrl);
|
||||
|
||||
sessionStorage.setItem("oauth_code_verifier", verifier);
|
||||
sessionStorage.setItem("oauth_state", state);
|
||||
sessionStorage.setItem("oauth_server_url", allowCustomJmapEndpoint ? jmapEndpoint : serverUrl!);
|
||||
sessionStorage.setItem("oauth_server_url", oauthServerUrl!);
|
||||
if (selectedServer?.id) {
|
||||
sessionStorage.setItem("oauth_server_id", selectedServer.id);
|
||||
} else {
|
||||
sessionStorage.removeItem("oauth_server_id");
|
||||
}
|
||||
if (isAddAccountMode) {
|
||||
sessionStorage.setItem("oauth_add_account_mode", "true");
|
||||
}
|
||||
@@ -473,7 +530,7 @@ export default function LoginPage() {
|
||||
|
||||
const authUrl = new URL(oauthMetadata.authorization_endpoint);
|
||||
authUrl.searchParams.set("response_type", "code");
|
||||
authUrl.searchParams.set("client_id", oauthClientId);
|
||||
authUrl.searchParams.set("client_id", effectiveOauthClientId);
|
||||
authUrl.searchParams.set("redirect_uri", redirectUri);
|
||||
authUrl.searchParams.set("scope", OAUTH_SCOPES);
|
||||
authUrl.searchParams.set("state", state);
|
||||
@@ -486,7 +543,10 @@ export default function LoginPage() {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
const effectiveServerUrl = allowCustomJmapEndpoint ? jmapEndpoint : serverUrl;
|
||||
// Server-list entries always win — `allowCustomJmapEndpoint` is only honored
|
||||
// when the admin hasn't configured a server list.
|
||||
const effectiveServerUrl = selectedServer?.url
|
||||
|| (allowCustomJmapEndpoint ? jmapEndpoint : serverUrl);
|
||||
const success = await login(
|
||||
effectiveServerUrl,
|
||||
formData.username,
|
||||
@@ -607,13 +667,17 @@ export default function LoginPage() {
|
||||
<div className="px-8 pb-10 pt-4">
|
||||
{error && (
|
||||
<div className={cn(
|
||||
"mb-5 p-3.5 bg-red-500/10 border border-red-500/20 rounded-xl flex items-start gap-3",
|
||||
"mb-5 p-3 rounded-xl border border-destructive/20 bg-destructive/5 flex items-start gap-3",
|
||||
shakeError && "animate-shake"
|
||||
)}>
|
||||
<AlertCircle className="w-4.5 h-4.5 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400 leading-relaxed">
|
||||
{t(`error.${error}`) || t("error.generic")}
|
||||
</p>
|
||||
<div className="w-10 h-10 rounded-full bg-destructive/15 text-destructive flex items-center justify-center flex-shrink-0 shadow-sm">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 self-center">
|
||||
<p className="text-sm text-destructive leading-relaxed">
|
||||
{t(`error.${error}`) || t("error.generic")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -754,37 +818,45 @@ export default function LoginPage() {
|
||||
{/* Session Expired Banner */}
|
||||
{sessionExpired && (
|
||||
<div
|
||||
className="mb-5 p-3.5 bg-blue-500/10 border border-blue-500/20 rounded-xl flex items-start gap-3"
|
||||
className="mb-5 p-3 rounded-xl border border-info/20 bg-info/5 flex items-start gap-3"
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
<Info className="w-4.5 h-4.5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-blue-700 dark:text-blue-300 flex-1 leading-relaxed">
|
||||
{t("session_expired")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSessionExpired(false)}
|
||||
className="p-0.5 rounded-md hover:bg-blue-500/10 transition-colors flex-shrink-0"
|
||||
aria-label={t("dismiss")}
|
||||
>
|
||||
<X className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
||||
</button>
|
||||
<div className="w-10 h-10 rounded-full bg-info/15 text-info flex items-center justify-center flex-shrink-0 shadow-sm">
|
||||
<Info className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 self-center flex items-center gap-2">
|
||||
<p className="text-sm text-info flex-1 leading-relaxed">
|
||||
{t("session_expired")}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSessionExpired(false)}
|
||||
className="p-1 rounded-md text-info hover:bg-info/10 transition-colors flex-shrink-0"
|
||||
aria-label={t("dismiss")}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className={cn(
|
||||
"mb-5 p-3.5 bg-red-500/10 border border-red-500/20 rounded-xl flex items-start gap-3",
|
||||
"mb-5 p-3 rounded-xl border border-destructive/20 bg-destructive/5 flex items-start gap-3",
|
||||
shakeError && "animate-shake"
|
||||
)}>
|
||||
<AlertCircle className="w-4.5 h-4.5 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-red-600 dark:text-red-400 leading-relaxed">
|
||||
{error === 'invalid_credentials' && showTotpField && totpCode
|
||||
? t('error.totp_invalid')
|
||||
: t(`error.${error}`) || t("error.generic")}
|
||||
</p>
|
||||
<div className="w-10 h-10 rounded-full bg-destructive/15 text-destructive flex items-center justify-center flex-shrink-0 shadow-sm">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 self-center">
|
||||
<p className="text-sm text-destructive leading-relaxed">
|
||||
{error === 'invalid_credentials' && showTotpField && totpCode
|
||||
? t('error.totp_invalid')
|
||||
: t(`error.${error}`) || t("error.generic")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -836,11 +908,15 @@ export default function LoginPage() {
|
||||
)}
|
||||
</Button>
|
||||
) : oauthDiscoveryDone ? (
|
||||
<div className="p-3.5 bg-warning/10 border border-warning/20 rounded-xl flex items-start gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-warning flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-warning">
|
||||
{t("error.oauth_discovery_failed")}
|
||||
</p>
|
||||
<div className="p-3 rounded-xl border border-warning/20 bg-warning/5 flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-warning/15 text-warning flex items-center justify-center flex-shrink-0 shadow-sm">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 self-center">
|
||||
<p className="text-sm text-warning leading-relaxed">
|
||||
{t("error.oauth_discovery_failed")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex justify-center py-4">
|
||||
@@ -852,8 +928,32 @@ export default function LoginPage() {
|
||||
/* Login Form */
|
||||
<form onSubmit={handleSubmit} className="space-y-5">
|
||||
<fieldset disabled={isLoading} className="space-y-4">
|
||||
{/* JMAP Endpoint field (when custom endpoints are allowed) */}
|
||||
{allowCustomJmapEndpoint && (
|
||||
{/* Server picker (when admin has configured a server list) */}
|
||||
{hasServerList && jmapServers.length > 1 && (
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="jmap-server-select" className="block text-sm font-medium text-foreground">
|
||||
{t("jmap_server_label")}
|
||||
</label>
|
||||
<select
|
||||
id="jmap-server-select"
|
||||
value={selectedServer?.id ?? ""}
|
||||
onChange={(e) => setSelectedServerId(e.target.value)}
|
||||
disabled={domainAutoLocked}
|
||||
className="h-11 w-full px-3.5 bg-muted/40 border border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200 text-sm text-foreground disabled:opacity-70 disabled:cursor-not-allowed"
|
||||
>
|
||||
{jmapServers.map((s) => (
|
||||
<option key={s.id} value={s.id}>{s.label}</option>
|
||||
))}
|
||||
</select>
|
||||
{domainAutoLocked && (
|
||||
<p className="text-[11px] text-muted-foreground leading-snug">
|
||||
{t("jmap_server_auto_picked")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{/* JMAP Endpoint field (only when no server list and custom endpoints are allowed) */}
|
||||
{!hasServerList && allowCustomJmapEndpoint && (
|
||||
<div className="space-y-1.5">
|
||||
<label htmlFor="jmap-endpoint" className="block text-sm font-medium text-foreground">
|
||||
{t("jmap_endpoint_label")}
|
||||
@@ -1068,11 +1168,15 @@ export default function LoginPage() {
|
||||
)}
|
||||
|
||||
{oauthEnabled && oauthDiscoveryDone && !oauthMetadata && (
|
||||
<div className="mt-2 p-3 bg-warning/10 border border-warning/20 rounded-xl flex items-start gap-2">
|
||||
<AlertCircle className="w-4 h-4 text-warning flex-shrink-0 mt-0.5" />
|
||||
<p className="text-sm text-warning">
|
||||
{t("error.oauth_discovery_failed")}
|
||||
</p>
|
||||
<div className="mt-2 p-3 rounded-xl border border-warning/20 bg-warning/5 flex items-start gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-warning/15 text-warning flex items-center justify-center flex-shrink-0 shadow-sm">
|
||||
<AlertCircle className="w-5 h-5" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0 self-center">
|
||||
<p className="text-sm text-warning leading-relaxed">
|
||||
{t("error.oauth_discovery_failed")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Plus, Trash2, RotateCcw, ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
|
||||
|
||||
interface Props {
|
||||
value: JmapServerEntry[];
|
||||
source?: string;
|
||||
onChange: (next: JmapServerEntry[]) => void;
|
||||
onRevert: () => void;
|
||||
}
|
||||
|
||||
interface RowDraft {
|
||||
id: string;
|
||||
label: string;
|
||||
url: string;
|
||||
domains: string;
|
||||
oauthClientId: string;
|
||||
oauthIssuerUrl: string;
|
||||
oauthClientSecret: string;
|
||||
oauthExpanded: boolean;
|
||||
}
|
||||
|
||||
function entryToDraft(e: JmapServerEntry): RowDraft {
|
||||
return {
|
||||
id: e.id,
|
||||
label: e.label,
|
||||
url: e.url,
|
||||
domains: (e.domains ?? []).join(', '),
|
||||
oauthClientId: e.oauth?.clientId ?? '',
|
||||
oauthIssuerUrl: e.oauth?.issuerUrl ?? '',
|
||||
oauthClientSecret: e.oauth?.clientSecret ?? '',
|
||||
oauthExpanded: !!(e.oauth && (e.oauth.clientId || e.oauth.issuerUrl || e.oauth.clientSecret)),
|
||||
};
|
||||
}
|
||||
|
||||
function draftToEntry(d: RowDraft): JmapServerEntry | null {
|
||||
const id = d.id.trim();
|
||||
const url = d.url.trim().replace(/\/+$/, '');
|
||||
if (!id || !url) return null;
|
||||
const domains = d.domains
|
||||
.split(/[,\s]+/)
|
||||
.map((s) => s.trim().toLowerCase())
|
||||
.filter(Boolean);
|
||||
const clientId = d.oauthClientId.trim();
|
||||
const issuerUrl = d.oauthIssuerUrl.trim().replace(/\/+$/, '');
|
||||
const clientSecret = d.oauthClientSecret;
|
||||
const oauth = clientId || issuerUrl || clientSecret
|
||||
? {
|
||||
...(clientId ? { clientId } : {}),
|
||||
...(issuerUrl ? { issuerUrl } : {}),
|
||||
...(clientSecret ? { clientSecret } : {}),
|
||||
}
|
||||
: undefined;
|
||||
return {
|
||||
id,
|
||||
label: d.label.trim() || id,
|
||||
url,
|
||||
...(domains.length > 0 ? { domains } : {}),
|
||||
...(oauth ? { oauth } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function emptyDraft(): RowDraft {
|
||||
return {
|
||||
id: '',
|
||||
label: '',
|
||||
url: '',
|
||||
domains: '',
|
||||
oauthClientId: '',
|
||||
oauthIssuerUrl: '',
|
||||
oauthClientSecret: '',
|
||||
oauthExpanded: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function JmapServersSection({ value, source, onChange, onRevert }: Props) {
|
||||
const [drafts, setDrafts] = useState<RowDraft[]>(() => value.map(entryToDraft));
|
||||
|
||||
useEffect(() => {
|
||||
// Re-sync from props when the underlying config value changes (e.g. revert,
|
||||
// initial load). Skip when drafts already represent the same array to avoid
|
||||
// clobbering in-progress edits.
|
||||
setDrafts((prev) => {
|
||||
if (prev.length === value.length) {
|
||||
const same = prev.every((d, i) => {
|
||||
const e = value[i];
|
||||
return d.id === e.id && d.url === e.url && d.label === e.label;
|
||||
});
|
||||
if (same) return prev;
|
||||
}
|
||||
return value.map(entryToDraft);
|
||||
});
|
||||
}, [value]);
|
||||
|
||||
function commit(next: RowDraft[]) {
|
||||
setDrafts(next);
|
||||
const entries: JmapServerEntry[] = [];
|
||||
for (const d of next) {
|
||||
const e = draftToEntry(d);
|
||||
if (e) entries.push(e);
|
||||
}
|
||||
onChange(entries);
|
||||
}
|
||||
|
||||
function update(idx: number, patch: Partial<RowDraft>) {
|
||||
commit(drafts.map((d, i) => (i === idx ? { ...d, ...patch } : d)));
|
||||
}
|
||||
|
||||
function remove(idx: number) {
|
||||
commit(drafts.filter((_, i) => i !== idx));
|
||||
}
|
||||
|
||||
function add() {
|
||||
setDrafts((prev) => [...prev, emptyDraft()]);
|
||||
// Don't commit yet - new row needs id+url before it counts.
|
||||
}
|
||||
|
||||
const ids = new Set<string>();
|
||||
const duplicateIdx = new Set<number>();
|
||||
drafts.forEach((d, i) => {
|
||||
const id = d.id.trim();
|
||||
if (!id) return;
|
||||
if (ids.has(id)) duplicateIdx.add(i);
|
||||
ids.add(id);
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="px-4 py-3 space-y-3">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-foreground">Servers</span>
|
||||
{source && source !== 'default' && (
|
||||
<span className={`text-[10px] font-medium uppercase tracking-wider px-1.5 py-0.5 rounded ${source === 'admin' ? 'bg-primary/10 text-primary' : 'bg-muted text-muted-foreground'}`}>
|
||||
{source}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-0.5">
|
||||
Each entry appears as an option on the login dropdown. Leave the list empty to fall back to the single <code className="text-[11px]">JMAP Server URL</code> above.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
{source === 'admin' && (
|
||||
<button
|
||||
onClick={onRevert}
|
||||
className="text-muted-foreground hover:text-foreground"
|
||||
title="Revert to default"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={add}
|
||||
className="inline-flex items-center gap-1.5 h-8 px-2.5 rounded-md border border-input bg-background text-xs text-foreground hover:bg-muted transition-colors"
|
||||
>
|
||||
<Plus className="w-3.5 h-3.5" />
|
||||
Add server
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{drafts.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground italic">No servers configured.</div>
|
||||
)}
|
||||
|
||||
{drafts.map((d, i) => {
|
||||
const isDuplicate = duplicateIdx.has(i);
|
||||
return (
|
||||
<div key={i} className="rounded-md border border-border bg-muted/20 p-3 space-y-2">
|
||||
<div className="grid grid-cols-1 sm:grid-cols-12 gap-2 items-start">
|
||||
<div className="sm:col-span-3">
|
||||
<label className="block text-[11px] font-medium text-muted-foreground mb-1">ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={d.id}
|
||||
onChange={(e) => update(i, { id: e.target.value })}
|
||||
placeholder="main"
|
||||
className={`h-8 w-full rounded-md border bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${isDuplicate ? 'border-destructive' : 'border-input'}`}
|
||||
/>
|
||||
{isDuplicate && <p className="text-[10px] text-destructive mt-0.5">Duplicate id</p>}
|
||||
</div>
|
||||
<div className="sm:col-span-4">
|
||||
<label className="block text-[11px] font-medium text-muted-foreground mb-1">Label</label>
|
||||
<input
|
||||
type="text"
|
||||
value={d.label}
|
||||
onChange={(e) => update(i, { label: e.target.value })}
|
||||
placeholder="Main server"
|
||||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<div className="sm:col-span-5">
|
||||
<label className="block text-[11px] font-medium text-muted-foreground mb-1">JMAP URL</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="url"
|
||||
value={d.url}
|
||||
onChange={(e) => update(i, { url: e.target.value })}
|
||||
placeholder="https://mail.example.com"
|
||||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
<button
|
||||
onClick={() => remove(i)}
|
||||
className="shrink-0 text-muted-foreground hover:text-destructive"
|
||||
title="Remove server"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-muted-foreground mb-1">
|
||||
Email domains (comma-separated, used for auto-pick)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={d.domains}
|
||||
onChange={(e) => update(i, { domains: e.target.value })}
|
||||
placeholder="example.com, example.org"
|
||||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => update(i, { oauthExpanded: !d.oauthExpanded })}
|
||||
className="inline-flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
||||
type="button"
|
||||
>
|
||||
{d.oauthExpanded ? <ChevronDown className="w-3.5 h-3.5" /> : <ChevronRight className="w-3.5 h-3.5" />}
|
||||
Per-server OAuth (optional, overrides global)
|
||||
</button>
|
||||
{d.oauthExpanded && (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 pl-4 border-l border-border">
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-muted-foreground mb-1">OAuth Client ID</label>
|
||||
<input
|
||||
type="text"
|
||||
value={d.oauthClientId}
|
||||
onChange={(e) => update(i, { oauthClientId: e.target.value })}
|
||||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-muted-foreground mb-1">OAuth Issuer URL</label>
|
||||
<input
|
||||
type="url"
|
||||
value={d.oauthIssuerUrl}
|
||||
onChange={(e) => update(i, { oauthIssuerUrl: e.target.value })}
|
||||
placeholder="https://auth.example.com"
|
||||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-[11px] font-medium text-muted-foreground mb-1">OAuth Client Secret</label>
|
||||
<input
|
||||
type="password"
|
||||
value={d.oauthClientSecret}
|
||||
onChange={(e) => update(i, { oauthClientSecret: e.target.value })}
|
||||
className="h-8 w-full rounded-md border border-input bg-background px-2 text-sm text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,8 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Save, RotateCcw, Loader2 } from 'lucide-react';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import { JmapServersSection } from './_jmap-servers-section';
|
||||
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
|
||||
|
||||
interface ConfigEntry {
|
||||
value: unknown;
|
||||
@@ -124,6 +126,31 @@ export function SettingsTab() {
|
||||
<ToggleSetting label="Demo Mode" description="Enable demo mode with sample data" configKey="demoMode" value={currentValue('demoMode') as boolean} source={config.demoMode?.source} onChange={handleChange} onRevert={handleRevert} />
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="JMAP Servers (multi-server)">
|
||||
<ToggleSetting
|
||||
label="Auto-pick server by email domain"
|
||||
description="When users type their email, automatically select the matching server from the list below."
|
||||
configKey="jmapServerAutoPickByDomain"
|
||||
value={currentValue('jmapServerAutoPickByDomain') as boolean}
|
||||
source={config.jmapServerAutoPickByDomain?.source}
|
||||
onChange={handleChange}
|
||||
onRevert={handleRevert}
|
||||
/>
|
||||
<JmapServersSection
|
||||
value={(currentValue('jmapServers') as JmapServerEntry[]) ?? []}
|
||||
source={config.jmapServers?.source}
|
||||
onChange={(next) => handleChange('jmapServers', next)}
|
||||
onRevert={() => handleRevert('jmapServers')}
|
||||
/>
|
||||
{Array.isArray(currentValue('jmapServers')) && (currentValue('jmapServers') as JmapServerEntry[]).length > 0 && (
|
||||
<div className="px-4 py-2.5 bg-amber-50 dark:bg-amber-950/30 border-l-2 border-amber-400 dark:border-amber-600">
|
||||
<p className="text-xs text-amber-800 dark:text-amber-300 leading-relaxed">
|
||||
<strong>CORS warning:</strong> Each JMAP server must allow this webmail's origin in its <code className="text-[11px] bg-amber-100 dark:bg-amber-900/50 px-1 py-0.5 rounded">Access-Control-Allow-Origin</code> header, or browser requests will be blocked.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</SettingsSection>
|
||||
|
||||
<SettingsSection title="Logging">
|
||||
<SelectSetting label="Log Format" configKey="logFormat" value={currentValue('logFormat') as string} source={config.logFormat?.source} options={['text', 'json']} onChange={handleChange} onRevert={handleRevert} />
|
||||
<SelectSetting label="Log Level" configKey="logLevel" value={currentValue('logLevel') as string} source={config.logLevel?.source} options={['error', 'warn', 'info', 'debug']} onChange={handleChange} onRevert={handleRevert} />
|
||||
|
||||
@@ -3,6 +3,7 @@ import { configManager } from '@/lib/admin/config-manager';
|
||||
import { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||
import { auditLog } from '@/lib/admin/audit';
|
||||
import { CONFIG_ENV_MAP } from '@/lib/admin/types';
|
||||
import { parseJmapServers } from '@/lib/admin/jmap-servers';
|
||||
import { logger } from '@/lib/logger';
|
||||
|
||||
/**
|
||||
@@ -47,6 +48,23 @@ export async function PATCH(request: NextRequest) {
|
||||
return NextResponse.json({ error: `Unknown config keys: ${invalidKeys.join(', ')}` }, { status: 400 });
|
||||
}
|
||||
|
||||
// Normalize jmapServers: pass through the parser so invalid entries are
|
||||
// rejected (bad ids, duplicate ids, non-HTTP URLs) before they're persisted.
|
||||
if ('jmapServers' in updates) {
|
||||
const incoming = updates.jmapServers;
|
||||
if (incoming != null && !Array.isArray(incoming)) {
|
||||
return NextResponse.json({ error: 'jmapServers must be an array' }, { status: 400 });
|
||||
}
|
||||
const sanitized = parseJmapServers(incoming);
|
||||
const incomingCount = Array.isArray(incoming) ? incoming.length : 0;
|
||||
if (sanitized.length !== incomingCount) {
|
||||
return NextResponse.json({
|
||||
error: 'One or more jmapServers entries are invalid (each needs a unique id, label, and HTTP(S) url).',
|
||||
}, { status: 400 });
|
||||
}
|
||||
updates.jmapServers = sanitized;
|
||||
}
|
||||
|
||||
// Get old values for audit
|
||||
const oldValues: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(updates)) {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
|
||||
|
||||
const COOKIE_OPTIONS = {
|
||||
...getCookieOptions(),
|
||||
@@ -39,10 +40,11 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Pin the upstream URL to the configured JMAP server so an unauthenticated
|
||||
// caller cannot point this route at internal hosts. Only when no server URL
|
||||
// is configured AND the deployment explicitly allows custom JMAP endpoints
|
||||
// do we honor the body URL — and even then it must be a public URL.
|
||||
// Pin the upstream URL to a configured JMAP server so an unauthenticated
|
||||
// caller cannot point this route at internal hosts. We accept the global
|
||||
// `jmapServerUrl` and any entry from `jmapServers`. When neither matches,
|
||||
// we fall back to the request URL only if `allowCustomJmapEndpoint` is on
|
||||
// — and even then the URL must resolve to a public address.
|
||||
await configManager.ensureLoaded();
|
||||
const configuredServerUrl =
|
||||
configManager.get<string>('jmapServerUrl', '') ||
|
||||
@@ -50,11 +52,13 @@ export async function POST(request: NextRequest) {
|
||||
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
|
||||
'';
|
||||
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
|
||||
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
|
||||
const trustedUrl = resolveTrustedJmapUrl(serverUrl, configuredServerUrl, serverList);
|
||||
|
||||
let upstreamUrl: string;
|
||||
let upstreamTrusted: boolean;
|
||||
if (configuredServerUrl) {
|
||||
upstreamUrl = configuredServerUrl;
|
||||
if (trustedUrl) {
|
||||
upstreamUrl = trustedUrl;
|
||||
upstreamTrusted = true;
|
||||
} else if (allowCustomEndpoint) {
|
||||
if (!(await isPublicHttpUrl(serverUrl))) {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { decryptPayload } from '@/lib/auth/crypto';
|
||||
import { exchangeCodeForTokens } from '@/lib/oauth/token-exchange';
|
||||
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
|
||||
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
|
||||
const SSO_PENDING_COOKIE = 'sso_pending';
|
||||
@@ -55,6 +55,7 @@ export async function POST(request: NextRequest) {
|
||||
|
||||
const codeVerifier = pending.code_verifier as string;
|
||||
const redirectUri = pending.redirect_uri as string;
|
||||
const pendingServerId = typeof pending.server_id === 'string' ? pending.server_id : null;
|
||||
|
||||
if (!codeVerifier || !redirectUri) {
|
||||
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||
@@ -62,13 +63,19 @@ export async function POST(request: NextRequest) {
|
||||
}
|
||||
|
||||
// Exchange code for tokens
|
||||
const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri);
|
||||
const tokens = await exchangeCodeForTokens(code, codeVerifier, redirectUri, pendingServerId);
|
||||
|
||||
// Store refresh token in the per-account cookie slot.
|
||||
if (tokens.refresh_token) {
|
||||
const cookieName = refreshTokenCookieName(slot);
|
||||
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||
}
|
||||
const serverCookieName = refreshTokenServerCookieName(slot);
|
||||
if (pendingServerId) {
|
||||
cookieStore.set(serverCookieName, pendingServerId, getCookieOptions());
|
||||
} else {
|
||||
cookieStore.delete(serverCookieName);
|
||||
}
|
||||
|
||||
// Delete pending cookie
|
||||
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||
|
||||
@@ -18,12 +18,14 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'SESSION_SECRET is required for SSO' }, { status: 500 });
|
||||
}
|
||||
|
||||
const { redirect_uri, locale } = await request.json();
|
||||
const { redirect_uri, locale, server_id: bodyServerId } = await request.json();
|
||||
|
||||
if (!redirect_uri || typeof redirect_uri !== 'string') {
|
||||
return NextResponse.json({ error: 'Missing redirect_uri' }, { status: 400 });
|
||||
}
|
||||
|
||||
const serverId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
|
||||
|
||||
// Validate redirect_uri origin matches the request origin to prevent open redirects
|
||||
const requestOrigin = request.headers.get('origin') || request.nextUrl.origin;
|
||||
try {
|
||||
@@ -36,7 +38,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Invalid redirect_uri' }, { status: 400 });
|
||||
}
|
||||
|
||||
const { clientId, discoveryUrl } = getRequiredConfig();
|
||||
const { clientId, discoveryUrl } = getRequiredConfig(serverId);
|
||||
const metadata = await discoverOAuth(discoveryUrl);
|
||||
|
||||
if (!metadata?.authorization_endpoint) {
|
||||
@@ -48,12 +50,14 @@ export async function POST(request: NextRequest) {
|
||||
const codeChallenge = generateCodeChallengeServer(codeVerifier);
|
||||
const state = generateStateServer();
|
||||
|
||||
// Encrypt and store in httpOnly cookie
|
||||
// Encrypt and store in httpOnly cookie. server_id is captured here so the
|
||||
// /complete handler reaches the same OAuth endpoint we used to authorize.
|
||||
const pendingData = {
|
||||
state,
|
||||
code_verifier: codeVerifier,
|
||||
redirect_uri,
|
||||
created_at: Date.now(),
|
||||
...(serverId ? { server_id: serverId } : {}),
|
||||
};
|
||||
|
||||
const encrypted = encryptPayload(pendingData);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
|
||||
|
||||
function getSlot(request: NextRequest, bodySlot: unknown): number {
|
||||
if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4) {
|
||||
@@ -26,10 +27,9 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Pin the upstream URL to the configured JMAP server so an unauthenticated
|
||||
// caller cannot point this route at internal hosts. Only when no server URL
|
||||
// is configured AND the deployment explicitly allows custom JMAP endpoints
|
||||
// do we honor the body URL — and even then it must be a public URL.
|
||||
// Pin the upstream URL to a configured JMAP server (single `jmapServerUrl`
|
||||
// or any entry in `jmapServers`). Falls back to the request URL only when
|
||||
// `allowCustomJmapEndpoint` is enabled, and even then it must be public.
|
||||
await configManager.ensureLoaded();
|
||||
const configuredServerUrl =
|
||||
configManager.get<string>('jmapServerUrl', '') ||
|
||||
@@ -37,11 +37,13 @@ export async function POST(request: NextRequest) {
|
||||
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
|
||||
'';
|
||||
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
|
||||
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
|
||||
const trustedUrl = resolveTrustedJmapUrl(serverUrl, configuredServerUrl, serverList);
|
||||
|
||||
let upstreamUrl: string;
|
||||
let upstreamTrusted: boolean;
|
||||
if (configuredServerUrl) {
|
||||
upstreamUrl = configuredServerUrl;
|
||||
if (trustedUrl) {
|
||||
upstreamUrl = trustedUrl;
|
||||
upstreamTrusted = true;
|
||||
} else if (allowCustomEndpoint) {
|
||||
if (!(await isPublicHttpUrl(serverUrl))) {
|
||||
|
||||
+27
-10
@@ -1,7 +1,7 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
|
||||
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
||||
import { exchangeCodeForTokens, buildOAuthParams, getMetadata, getTokenEndpoint } from '@/lib/oauth/token-exchange';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
|
||||
@@ -15,26 +15,36 @@ function getSlot(request: NextRequest): number {
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { code, code_verifier, redirect_uri, slot: bodySlot } = await request.json();
|
||||
const { code, code_verifier, redirect_uri, slot: bodySlot, server_id: bodyServerId } = await request.json();
|
||||
|
||||
if (!code || !code_verifier || !redirect_uri) {
|
||||
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
|
||||
}
|
||||
|
||||
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
|
||||
const serverId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
|
||||
|
||||
const tokens = await exchangeCodeForTokens(code, code_verifier, redirect_uri);
|
||||
const tokens = await exchangeCodeForTokens(code, code_verifier, redirect_uri, serverId);
|
||||
|
||||
const response = NextResponse.json({
|
||||
access_token: tokens.access_token,
|
||||
expires_in: tokens.expires_in,
|
||||
});
|
||||
|
||||
const cookieStore = await cookies();
|
||||
if (tokens.refresh_token) {
|
||||
const cookieName = refreshTokenCookieName(slot);
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||
}
|
||||
// Persist which server entry minted this refresh token so the PUT/DELETE
|
||||
// handlers can route the refresh/revocation calls to the right token
|
||||
// endpoint without the client having to track it across page loads.
|
||||
const serverCookieName = refreshTokenServerCookieName(slot);
|
||||
if (serverId) {
|
||||
cookieStore.set(serverCookieName, serverId, getCookieOptions());
|
||||
} else {
|
||||
cookieStore.delete(serverCookieName);
|
||||
}
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
@@ -49,17 +59,18 @@ export async function PUT(request: NextRequest) {
|
||||
const cookieName = refreshTokenCookieName(slot);
|
||||
const cookieStore = await cookies();
|
||||
const refreshToken = cookieStore.get(cookieName)?.value;
|
||||
const serverId = cookieStore.get(refreshTokenServerCookieName(slot))?.value || null;
|
||||
|
||||
if (!refreshToken) {
|
||||
return NextResponse.json({ error: 'No refresh token' }, { status: 401 });
|
||||
}
|
||||
|
||||
const tokenEndpoint = await getTokenEndpoint();
|
||||
const tokenEndpoint = await getTokenEndpoint(serverId);
|
||||
|
||||
const params = buildOAuthParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
});
|
||||
}, serverId);
|
||||
|
||||
const tokenResponse = await fetch(tokenEndpoint, {
|
||||
method: 'POST',
|
||||
@@ -71,6 +82,7 @@ export async function PUT(request: NextRequest) {
|
||||
const errorText = await tokenResponse.text();
|
||||
logger.error('Token refresh failed', { status: tokenResponse.status, error: errorText });
|
||||
cookieStore.delete(cookieName);
|
||||
cookieStore.delete(refreshTokenServerCookieName(slot));
|
||||
return NextResponse.json({ error: 'Refresh failed' }, { status: 401 });
|
||||
}
|
||||
|
||||
@@ -104,13 +116,15 @@ export async function DELETE(request: NextRequest) {
|
||||
const cookieStore = await cookies();
|
||||
for (let i = 0; i <= 4; i++) {
|
||||
const name = refreshTokenCookieName(i);
|
||||
const serverCookieName = refreshTokenServerCookieName(i);
|
||||
const token = cookieStore.get(name)?.value;
|
||||
const slotServerId = cookieStore.get(serverCookieName)?.value || null;
|
||||
if (token) {
|
||||
// Best-effort revocation
|
||||
try {
|
||||
const metadata = await getMetadata().catch(() => null);
|
||||
const metadata = await getMetadata(slotServerId).catch(() => null);
|
||||
if (metadata?.revocation_endpoint) {
|
||||
const params = buildOAuthParams({ token, token_type_hint: 'refresh_token' });
|
||||
const params = buildOAuthParams({ token, token_type_hint: 'refresh_token' }, slotServerId);
|
||||
await fetch(metadata.revocation_endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
@@ -120,6 +134,7 @@ export async function DELETE(request: NextRequest) {
|
||||
} catch { /* best effort */ }
|
||||
cookieStore.delete(name);
|
||||
}
|
||||
cookieStore.delete(serverCookieName);
|
||||
}
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -128,7 +143,8 @@ export async function DELETE(request: NextRequest) {
|
||||
const cookieName = refreshTokenCookieName(slot);
|
||||
const cookieStore = await cookies();
|
||||
const refreshToken = cookieStore.get(cookieName)?.value;
|
||||
const metadata = await getMetadata().catch((err) => {
|
||||
const slotServerId = cookieStore.get(refreshTokenServerCookieName(slot))?.value || null;
|
||||
const metadata = await getMetadata(slotServerId).catch((err) => {
|
||||
logger.warn('Failed to discover OAuth metadata during logout', {
|
||||
error: err instanceof Error ? err.message : 'Unknown error',
|
||||
});
|
||||
@@ -140,7 +156,7 @@ export async function DELETE(request: NextRequest) {
|
||||
const params = buildOAuthParams({
|
||||
token: refreshToken,
|
||||
token_type_hint: 'refresh_token',
|
||||
});
|
||||
}, slotServerId);
|
||||
|
||||
try {
|
||||
const revocationResponse = await fetch(metadata.revocation_endpoint, {
|
||||
@@ -158,6 +174,7 @@ export async function DELETE(request: NextRequest) {
|
||||
|
||||
cookieStore.delete(cookieName);
|
||||
}
|
||||
cookieStore.delete(refreshTokenServerCookieName(slot));
|
||||
|
||||
let end_session_url: string | undefined;
|
||||
if (metadata?.end_session_endpoint) {
|
||||
|
||||
@@ -2,12 +2,13 @@ import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
||||
import { refreshTokenCookieName } from '@/lib/oauth/tokens';
|
||||
import { refreshTokenCookieName, refreshTokenServerCookieName } from '@/lib/oauth/tokens';
|
||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||
import { readFileEnv } from '@/lib/read-file-env';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||
import { parseJmapServers, findServerByUrl, findServerById } from '@/lib/admin/jmap-servers';
|
||||
|
||||
/**
|
||||
* Exchange basic auth credentials (with TOTP appended) for OAuth tokens.
|
||||
@@ -78,18 +79,19 @@ async function findTokenEndpoint(serverUrl: string): Promise<string | null> {
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const { serverUrl, username, password, slot: bodySlot } = await request.json();
|
||||
const { serverUrl, username, password, slot: bodySlot, server_id: bodyServerId } = await request.json();
|
||||
|
||||
if (!serverUrl || !username || !password) {
|
||||
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
|
||||
}
|
||||
|
||||
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : 0;
|
||||
const requestedServerId = typeof bodyServerId === 'string' && bodyServerId ? bodyServerId : null;
|
||||
|
||||
// Pin the upstream URL to the configured JMAP server so an unauthenticated
|
||||
// caller cannot point this route at internal hosts. Only when no server
|
||||
// URL is configured (and the deployment explicitly allows custom JMAP
|
||||
// endpoints) do we fall back to the user-supplied URL - and even then
|
||||
// Pin the upstream URL to a configured JMAP server. The list of allowed
|
||||
// servers is `jmapServerUrl` plus any entry from `jmapServers`. Only when
|
||||
// no server is configured (and the deployment explicitly allows custom
|
||||
// JMAP endpoints) do we fall back to the user-supplied URL — and even then
|
||||
// it must resolve to a public address.
|
||||
await configManager.ensureLoaded();
|
||||
const configuredServerUrl =
|
||||
@@ -98,9 +100,17 @@ export async function POST(request: NextRequest) {
|
||||
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
|
||||
'';
|
||||
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
|
||||
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
|
||||
|
||||
let upstreamUrl: string;
|
||||
if (configuredServerUrl) {
|
||||
let resolvedServerId: string | null = null;
|
||||
const requestedEntry = findServerById(serverList, requestedServerId);
|
||||
const matchedEntry = requestedEntry || findServerByUrl(serverList, serverUrl);
|
||||
|
||||
if (matchedEntry) {
|
||||
upstreamUrl = matchedEntry.url;
|
||||
resolvedServerId = matchedEntry.id;
|
||||
} else if (configuredServerUrl) {
|
||||
upstreamUrl = configuredServerUrl;
|
||||
} else if (allowCustomEndpoint) {
|
||||
if (!(await isPublicHttpUrl(serverUrl))) {
|
||||
@@ -118,7 +128,7 @@ export async function POST(request: NextRequest) {
|
||||
return NextResponse.json({ error: 'no_token_endpoint', detail: 'Could not discover OAuth token endpoint on the mail server' }, { status: 404 });
|
||||
}
|
||||
|
||||
return await attemptAllStrategies(tokenEndpoint, upstreamUrl, username, password, slot);
|
||||
return await attemptAllStrategies(tokenEndpoint, upstreamUrl, username, password, slot, resolvedServerId);
|
||||
} catch (error) {
|
||||
logger.error('TOTP token exchange error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||
@@ -131,11 +141,21 @@ async function attemptAllStrategies(
|
||||
username: string,
|
||||
password: string,
|
||||
slot: number,
|
||||
serverId: string | null,
|
||||
): Promise<NextResponse> {
|
||||
logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint });
|
||||
|
||||
const clientId = configManager.get<string>('oauthClientId', '') || process.env.OAUTH_CLIENT_ID;
|
||||
const clientSecret = configManager.get<string>('oauthClientSecret', '') || process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE);
|
||||
// Per-server OAuth credentials override the global ones when the requested
|
||||
// server entry has its own oauth block configured.
|
||||
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
|
||||
const entry = findServerById(serverList, serverId);
|
||||
const clientId = entry?.oauth?.clientId
|
||||
|| configManager.get<string>('oauthClientId', '')
|
||||
|| process.env.OAUTH_CLIENT_ID;
|
||||
const clientSecret = entry?.oauth?.clientSecret
|
||||
|| configManager.get<string>('oauthClientSecret', '')
|
||||
|| process.env.OAUTH_CLIENT_SECRET
|
||||
|| readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE);
|
||||
const basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||
const attempts: Array<{ strategy: string; error: string }> = [];
|
||||
|
||||
@@ -147,7 +167,7 @@ async function attemptAllStrategies(
|
||||
if (result.ok) {
|
||||
logger.info('TOTP token exchange succeeded (ROPC with client_id)');
|
||||
void recordLogin(username, serverUrl);
|
||||
return await storeAndRespond(result.tokens, slot);
|
||||
return await storeAndRespond(result.tokens, slot, serverId);
|
||||
}
|
||||
attempts.push({ strategy: 'ROPC with client_id', error: result.error });
|
||||
}
|
||||
@@ -159,7 +179,7 @@ async function attemptAllStrategies(
|
||||
if (result.ok) {
|
||||
logger.info('TOTP token exchange succeeded (ROPC without client_id)');
|
||||
void recordLogin(username, serverUrl);
|
||||
return await storeAndRespond(result.tokens, slot);
|
||||
return await storeAndRespond(result.tokens, slot, serverId);
|
||||
}
|
||||
attempts.push({ strategy: 'ROPC without client_id', error: result.error });
|
||||
}
|
||||
@@ -171,7 +191,7 @@ async function attemptAllStrategies(
|
||||
if (result.ok) {
|
||||
logger.info('TOTP token exchange succeeded (Basic Auth header)');
|
||||
void recordLogin(username, serverUrl);
|
||||
return await storeAndRespond(result.tokens, slot);
|
||||
return await storeAndRespond(result.tokens, slot, serverId);
|
||||
}
|
||||
attempts.push({ strategy: 'Basic Auth header', error: result.error });
|
||||
}
|
||||
@@ -183,7 +203,7 @@ async function attemptAllStrategies(
|
||||
if (result.ok) {
|
||||
logger.info('TOTP token exchange succeeded (client_credentials + Basic Auth)');
|
||||
void recordLogin(username, serverUrl);
|
||||
return await storeAndRespond(result.tokens, slot);
|
||||
return await storeAndRespond(result.tokens, slot, serverId);
|
||||
}
|
||||
attempts.push({ strategy: 'client_credentials + Basic Auth', error: result.error });
|
||||
}
|
||||
@@ -199,12 +219,19 @@ async function attemptAllStrategies(
|
||||
async function storeAndRespond(
|
||||
tokens: { access_token: string; expires_in?: number; refresh_token?: string },
|
||||
slot: number,
|
||||
serverId: string | null,
|
||||
): Promise<NextResponse> {
|
||||
const cookieStore = await cookies();
|
||||
if (tokens.refresh_token) {
|
||||
const cookieName = refreshTokenCookieName(slot);
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
||||
}
|
||||
const serverCookieName = refreshTokenServerCookieName(slot);
|
||||
if (serverId) {
|
||||
cookieStore.set(serverCookieName, serverId, getCookieOptions());
|
||||
} else {
|
||||
cookieStore.delete(serverCookieName);
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
access_token: tokens.access_token,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
|
||||
import { logger } from '@/lib/logger';
|
||||
import { configManager } from '@/lib/admin/config-manager';
|
||||
import { readFileEnv } from '@/lib/read-file-env';
|
||||
import { parseJmapServers, redactJmapServers } from '@/lib/admin/jmap-servers';
|
||||
|
||||
/**
|
||||
* Runtime configuration endpoint
|
||||
@@ -49,6 +50,8 @@ export async function GET() {
|
||||
loginWebsiteUrl: configManager.get<string>('loginWebsiteUrl', ''),
|
||||
demoMode: configManager.get<boolean>('demoMode', false),
|
||||
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', false),
|
||||
jmapServers: redactJmapServers(parseJmapServers(configManager.get<unknown>('jmapServers', []))),
|
||||
jmapServerAutoPickByDomain: configManager.get<boolean>('jmapServerAutoPickByDomain', false),
|
||||
autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false),
|
||||
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
|
||||
parentOrigin: configManager.get<string>('parentOrigin', ''),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { usePolicyStore } from '@/stores/policy-store';
|
||||
import { apiFetch } from '@/lib/browser-navigation';
|
||||
import type { PublicJmapServerEntry } from '@/lib/admin/jmap-servers';
|
||||
|
||||
interface ConfigData {
|
||||
appName: string;
|
||||
@@ -27,6 +28,8 @@ interface ConfigData {
|
||||
demoMode: boolean;
|
||||
autoSsoEnabled: boolean;
|
||||
allowCustomJmapEndpoint: boolean;
|
||||
jmapServers: PublicJmapServerEntry[];
|
||||
jmapServerAutoPickByDomain: boolean;
|
||||
embeddedMode: boolean;
|
||||
parentOrigin: string;
|
||||
}
|
||||
@@ -103,6 +106,8 @@ export function useConfig(): AppConfig {
|
||||
demoMode: configCache?.demoMode || false,
|
||||
autoSsoEnabled: configCache?.autoSsoEnabled || false,
|
||||
allowCustomJmapEndpoint: configCache?.allowCustomJmapEndpoint || false,
|
||||
jmapServers: configCache?.jmapServers || [],
|
||||
jmapServerAutoPickByDomain: configCache?.jmapServerAutoPickByDomain || false,
|
||||
embeddedMode: configCache?.embeddedMode || false,
|
||||
parentOrigin: configCache?.parentOrigin || '',
|
||||
isLoading: !configCache,
|
||||
@@ -135,6 +140,8 @@ export function useConfig(): AppConfig {
|
||||
demoMode: configCache.demoMode,
|
||||
autoSsoEnabled: configCache.autoSsoEnabled,
|
||||
allowCustomJmapEndpoint: configCache.allowCustomJmapEndpoint,
|
||||
jmapServers: configCache.jmapServers || [],
|
||||
jmapServerAutoPickByDomain: configCache.jmapServerAutoPickByDomain || false,
|
||||
embeddedMode: configCache.embeddedMode,
|
||||
parentOrigin: configCache.parentOrigin,
|
||||
isLoading: false,
|
||||
@@ -168,6 +175,8 @@ export function useConfig(): AppConfig {
|
||||
demoMode: data.demoMode,
|
||||
autoSsoEnabled: data.autoSsoEnabled,
|
||||
allowCustomJmapEndpoint: data.allowCustomJmapEndpoint,
|
||||
jmapServers: data.jmapServers || [],
|
||||
jmapServerAutoPickByDomain: data.jmapServerAutoPickByDomain || false,
|
||||
embeddedMode: data.embeddedMode,
|
||||
parentOrigin: data.parentOrigin,
|
||||
isLoading: false,
|
||||
|
||||
@@ -13,6 +13,12 @@ function parseEnvValue(value: string, type: string): unknown {
|
||||
switch (type) {
|
||||
case 'boolean':
|
||||
return value === 'true';
|
||||
case 'json':
|
||||
try {
|
||||
return JSON.parse(value);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
case 'string':
|
||||
case 'url':
|
||||
case 'enum':
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
/**
|
||||
* Multi-server JMAP support: schema, parsing, lookup, and redaction helpers.
|
||||
*/
|
||||
|
||||
export interface JmapServerOAuthConfig {
|
||||
clientId?: string;
|
||||
issuerUrl?: string;
|
||||
clientSecret?: string;
|
||||
}
|
||||
|
||||
export interface JmapServerEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
url: string;
|
||||
domains?: string[];
|
||||
oauth?: JmapServerOAuthConfig;
|
||||
}
|
||||
|
||||
export interface PublicJmapServerEntry {
|
||||
id: string;
|
||||
label: string;
|
||||
url: string;
|
||||
domains: string[];
|
||||
oauth?: {
|
||||
clientId?: string;
|
||||
issuerUrl?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i;
|
||||
|
||||
function trimUrl(url: string): string {
|
||||
return url.trim().replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function isHttpUrl(url: string): boolean {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.protocol === 'https:' || u.protocol === 'http:';
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parse the raw config value (may be array, string JSON, or null). */
|
||||
export function parseJmapServers(raw: unknown): JmapServerEntry[] {
|
||||
if (!raw) return [];
|
||||
let value = raw;
|
||||
if (typeof value === 'string') {
|
||||
if (!value.trim()) return [];
|
||||
try {
|
||||
value = JSON.parse(value);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(value)) return [];
|
||||
const seen = new Set<string>();
|
||||
const out: JmapServerEntry[] = [];
|
||||
for (const item of value) {
|
||||
if (!item || typeof item !== 'object') continue;
|
||||
const e = item as Record<string, unknown>;
|
||||
const id = typeof e.id === 'string' ? e.id.trim() : '';
|
||||
const label = typeof e.label === 'string' ? e.label.trim() : '';
|
||||
const url = typeof e.url === 'string' ? trimUrl(e.url) : '';
|
||||
if (!id || !ID_RE.test(id) || seen.has(id)) continue;
|
||||
if (!url || !isHttpUrl(url)) continue;
|
||||
seen.add(id);
|
||||
const domains = Array.isArray(e.domains)
|
||||
? e.domains
|
||||
.filter((d): d is string => typeof d === 'string')
|
||||
.map((d) => d.trim().toLowerCase())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
let oauth: JmapServerOAuthConfig | undefined;
|
||||
if (e.oauth && typeof e.oauth === 'object') {
|
||||
const o = e.oauth as Record<string, unknown>;
|
||||
const clientId = typeof o.clientId === 'string' ? o.clientId.trim() : '';
|
||||
const issuerUrl = typeof o.issuerUrl === 'string' ? trimUrl(o.issuerUrl) : '';
|
||||
const clientSecret = typeof o.clientSecret === 'string' ? o.clientSecret : '';
|
||||
if (clientId || issuerUrl || clientSecret) {
|
||||
oauth = {};
|
||||
if (clientId) oauth.clientId = clientId;
|
||||
if (issuerUrl && isHttpUrl(issuerUrl)) oauth.issuerUrl = issuerUrl;
|
||||
if (clientSecret) oauth.clientSecret = clientSecret;
|
||||
}
|
||||
}
|
||||
out.push({
|
||||
id,
|
||||
label: label || id,
|
||||
url,
|
||||
...(domains.length > 0 ? { domains } : {}),
|
||||
...(oauth ? { oauth } : {}),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Strip secrets for client-side exposure. */
|
||||
export function redactJmapServers(servers: JmapServerEntry[]): PublicJmapServerEntry[] {
|
||||
return servers.map((s) => ({
|
||||
id: s.id,
|
||||
label: s.label,
|
||||
url: s.url,
|
||||
domains: s.domains ?? [],
|
||||
...(s.oauth && (s.oauth.clientId || s.oauth.issuerUrl)
|
||||
? {
|
||||
oauth: {
|
||||
...(s.oauth.clientId ? { clientId: s.oauth.clientId } : {}),
|
||||
...(s.oauth.issuerUrl ? { issuerUrl: s.oauth.issuerUrl } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
}));
|
||||
}
|
||||
|
||||
export function findServerById(servers: JmapServerEntry[], id: string | null | undefined): JmapServerEntry | undefined {
|
||||
if (!id) return undefined;
|
||||
return servers.find((s) => s.id === id);
|
||||
}
|
||||
|
||||
function normalizeUrl(url: string): string {
|
||||
try {
|
||||
const u = new URL(trimUrl(url));
|
||||
return `${u.protocol}//${u.host.toLowerCase()}${u.pathname.replace(/\/+$/, '')}`;
|
||||
} catch {
|
||||
return trimUrl(url).toLowerCase();
|
||||
}
|
||||
}
|
||||
|
||||
export function findServerByUrl(servers: JmapServerEntry[], url: string | null | undefined): JmapServerEntry | undefined {
|
||||
if (!url) return undefined;
|
||||
const target = normalizeUrl(url);
|
||||
return servers.find((s) => normalizeUrl(s.url) === target);
|
||||
}
|
||||
|
||||
/** Find the server whose `domains` array matches the given email's domain (case-insensitive). */
|
||||
export function findServerByEmailDomain(servers: JmapServerEntry[], email: string | null | undefined): JmapServerEntry | undefined {
|
||||
if (!email || !email.includes('@')) return undefined;
|
||||
const domain = email.split('@')[1]?.trim().toLowerCase();
|
||||
if (!domain) return undefined;
|
||||
return servers.find((s) => (s.domains ?? []).some((d) => d.toLowerCase() === domain));
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a client-supplied JMAP URL to a trusted upstream URL by checking it
|
||||
* against the configured server list and the global `jmapServerUrl`. Returns
|
||||
* null when no match is found. Used by API routes that need to forward auth
|
||||
* requests upstream without being tricked into hitting internal hosts.
|
||||
*/
|
||||
export function resolveTrustedJmapUrl(
|
||||
requestedUrl: string | null | undefined,
|
||||
globalServerUrl: string | null | undefined,
|
||||
servers: JmapServerEntry[],
|
||||
): string | null {
|
||||
if (!requestedUrl) {
|
||||
return globalServerUrl ? trimUrl(globalServerUrl) : null;
|
||||
}
|
||||
const target = normalizeUrl(requestedUrl);
|
||||
if (globalServerUrl && normalizeUrl(globalServerUrl) === target) {
|
||||
return trimUrl(globalServerUrl);
|
||||
}
|
||||
const matched = servers.find((s) => normalizeUrl(s.url) === target);
|
||||
if (matched) return matched.url;
|
||||
// No match — caller decides whether to honor the request anyway (e.g. when
|
||||
// allowCustomJmapEndpoint is enabled).
|
||||
return null;
|
||||
}
|
||||
+3
-1
@@ -108,7 +108,7 @@ export interface AuditEntry {
|
||||
}
|
||||
|
||||
/** Config keys that map to environment variables */
|
||||
export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: string; type: 'string' | 'boolean' | 'url' | 'enum'; defaultValue: unknown; enumValues?: string[] }> = {
|
||||
export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: string; type: 'string' | 'boolean' | 'url' | 'enum' | 'json'; defaultValue: unknown; enumValues?: string[] }> = {
|
||||
appName: { envVar: 'APP_NAME', type: 'string', defaultValue: 'Webmail' },
|
||||
jmapServerUrl: { envVar: 'JMAP_SERVER_URL', type: 'url', defaultValue: '' },
|
||||
stalwartFeaturesEnabled: { envVar: 'STALWART_FEATURES', type: 'boolean', defaultValue: true },
|
||||
@@ -129,6 +129,8 @@ export const CONFIG_ENV_MAP: Record<string, { envVar: string; fileEnvVar?: strin
|
||||
oauthClientSecret: { envVar: 'OAUTH_CLIENT_SECRET', fileEnvVar: 'OAUTH_CLIENT_SECRET_FILE', type: 'string', defaultValue: '' },
|
||||
oauthIssuerUrl: { envVar: 'OAUTH_ISSUER_URL', type: 'url', defaultValue: '' },
|
||||
allowCustomJmapEndpoint: { envVar: 'ALLOW_CUSTOM_JMAP_ENDPOINT', type: 'boolean', defaultValue: false },
|
||||
jmapServers: { envVar: 'JMAP_SERVERS', type: 'json', defaultValue: [] },
|
||||
jmapServerAutoPickByDomain: { envVar: 'JMAP_SERVER_AUTO_PICK_BY_DOMAIN', type: 'boolean', defaultValue: false },
|
||||
autoSsoEnabled: { envVar: 'AUTO_SSO_ENABLED', type: 'boolean', defaultValue: false },
|
||||
cookieSameSite: { envVar: 'COOKIE_SAME_SITE', type: 'enum', defaultValue: 'lax', enumValues: ['lax', 'strict', 'none'] },
|
||||
allowedFrameAncestors: { envVar: 'ALLOWED_FRAME_ANCESTORS', type: 'string', defaultValue: '' },
|
||||
|
||||
+36
-15
@@ -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',
|
||||
|
||||
@@ -2,8 +2,14 @@ const DEFAULT_SCOPES = 'openid email profile';
|
||||
const EXTRA_SCOPES = process.env.OAUTH_EXTRA_SCOPES || '';
|
||||
export const OAUTH_SCOPES = process.env.OAUTH_SCOPES || (EXTRA_SCOPES ? `${DEFAULT_SCOPES} ${EXTRA_SCOPES}`.trim() : DEFAULT_SCOPES);
|
||||
export const REFRESH_TOKEN_COOKIE = 'jmap_rt';
|
||||
export const REFRESH_TOKEN_SERVER_COOKIE = 'jmap_rts';
|
||||
|
||||
/** Get the cookie name for a given account slot (0-4). Slot 0 uses the legacy name. */
|
||||
export function refreshTokenCookieName(slot: number): string {
|
||||
return slot === 0 ? REFRESH_TOKEN_COOKIE : `${REFRESH_TOKEN_COOKIE}_${slot}`;
|
||||
}
|
||||
|
||||
/** Companion cookie storing which server entry id minted the refresh token at this slot. */
|
||||
export function refreshTokenServerCookieName(slot: number): string {
|
||||
return slot === 0 ? REFRESH_TOKEN_SERVER_COOKIE : `${REFRESH_TOKEN_SERVER_COOKIE}_${slot}`;
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
"jmap_endpoint_label": "JMAP Server",
|
||||
"jmap_endpoint_placeholder": "https://mail.example.com",
|
||||
"jmap_endpoint_cors_hint": "The server must allow CORS requests from this domain.",
|
||||
"jmap_server_label": "Server",
|
||||
"jmap_server_auto_picked": "Server selected from your email domain.",
|
||||
"sign_in": "Sign in",
|
||||
"signing_in": "Signing in...",
|
||||
"loading": "Loading...",
|
||||
|
||||
+12
-3
@@ -37,7 +37,7 @@ interface AuthState {
|
||||
isDemoMode: boolean;
|
||||
|
||||
login: (serverUrl: string, username: string, password: string, totp?: string, rememberMe?: boolean) => Promise<boolean>;
|
||||
loginWithOAuth: (serverUrl: string, code: string, codeVerifier: string, redirectUri: string) => Promise<boolean>;
|
||||
loginWithOAuth: (serverUrl: string, code: string, codeVerifier: string, redirectUri: string, serverId?: string) => Promise<boolean>;
|
||||
loginWithServerSso: (code: string, state: string) => Promise<boolean>;
|
||||
loginDemo: () => Promise<boolean>;
|
||||
refreshAccessToken: () => Promise<string | null>;
|
||||
@@ -408,6 +408,9 @@ export const useAuthStore = create<AuthState>()(
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ serverUrl, username, password: effectivePassword, slot: cookieSlot }),
|
||||
// Note: server_id isn't passed here — the route looks up the
|
||||
// server entry by serverUrl, so per-server OAuth still applies
|
||||
// for password+TOTP logins through the dropdown.
|
||||
});
|
||||
if (tokenRes.ok) {
|
||||
const { access_token, expires_in, has_refresh_token } = await tokenRes.json();
|
||||
@@ -597,7 +600,7 @@ export const useAuthStore = create<AuthState>()(
|
||||
}
|
||||
},
|
||||
|
||||
loginWithOAuth: async (serverUrl, code, codeVerifier, redirectUri) => {
|
||||
loginWithOAuth: async (serverUrl, code, codeVerifier, redirectUri, serverId) => {
|
||||
set({ isLoading: true, error: null, isRateLimited: false, rateLimitUntil: null });
|
||||
|
||||
try {
|
||||
@@ -620,7 +623,13 @@ export const useAuthStore = create<AuthState>()(
|
||||
const tokenRes = await apiFetch(`/api/auth/token?slot=${slot}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ code, code_verifier: codeVerifier, redirect_uri: redirectUri, slot }),
|
||||
body: JSON.stringify({
|
||||
code,
|
||||
code_verifier: codeVerifier,
|
||||
redirect_uri: redirectUri,
|
||||
slot,
|
||||
...(serverId ? { server_id: serverId } : {}),
|
||||
}),
|
||||
});
|
||||
|
||||
if (!tokenRes.ok) {
|
||||
|
||||
Reference in New Issue
Block a user