feat: multi-server JMAP support
This commit is contained in:
@@ -43,6 +43,7 @@ function OAuthCallbackInner() {
|
|||||||
|
|
||||||
const codeVerifier = sessionStorage.getItem("oauth_code_verifier");
|
const codeVerifier = sessionStorage.getItem("oauth_code_verifier");
|
||||||
const serverUrl = sessionStorage.getItem("oauth_server_url");
|
const serverUrl = sessionStorage.getItem("oauth_server_url");
|
||||||
|
const serverId = sessionStorage.getItem("oauth_server_id") || undefined;
|
||||||
|
|
||||||
if (!codeVerifier || !serverUrl) {
|
if (!codeVerifier || !serverUrl) {
|
||||||
setError("missing_params");
|
setError("missing_params");
|
||||||
@@ -52,12 +53,13 @@ function OAuthCallbackInner() {
|
|||||||
const prefix = getPathPrefix(params.locale as string);
|
const prefix = getPathPrefix(params.locale as string);
|
||||||
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
|
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
|
||||||
|
|
||||||
loginWithOAuth(serverUrl, code, codeVerifier, redirectUri)
|
loginWithOAuth(serverUrl, code, codeVerifier, redirectUri, serverId)
|
||||||
.then((success) => {
|
.then((success) => {
|
||||||
if (success) {
|
if (success) {
|
||||||
sessionStorage.removeItem("oauth_state");
|
sessionStorage.removeItem("oauth_state");
|
||||||
sessionStorage.removeItem("oauth_code_verifier");
|
sessionStorage.removeItem("oauth_code_verifier");
|
||||||
sessionStorage.removeItem("oauth_server_url");
|
sessionStorage.removeItem("oauth_server_url");
|
||||||
|
sessionStorage.removeItem("oauth_server_id");
|
||||||
sessionStorage.removeItem("oauth_add_account_mode");
|
sessionStorage.removeItem("oauth_add_account_mode");
|
||||||
let redirectTo = `${prefix}/${params.locale}`;
|
let redirectTo = `${prefix}/${params.locale}`;
|
||||||
try {
|
try {
|
||||||
|
|||||||
+150
-46
@@ -18,6 +18,14 @@ import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
|
|||||||
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
|
||||||
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
|
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
|
||||||
import { useUpdateStore, selectBanner } from "@/stores/update-store";
|
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 APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0";
|
||||||
const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown";
|
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 isAddAccountMode = searchParams.get("mode") === "add-account";
|
||||||
const { login, loginDemo, isLoading, error, clearError, isAuthenticated } = useAuthStore();
|
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 { 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 resolvedTheme = useThemeStore((s) => s.resolvedTheme);
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
@@ -117,6 +125,18 @@ export default function LoginPage() {
|
|||||||
password: "",
|
password: "",
|
||||||
});
|
});
|
||||||
const [jmapEndpoint, setJmapEndpoint] = useState("");
|
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 [totpCode, setTotpCode] = useState("");
|
||||||
const [showTotpField, setShowTotpField] = useState(false);
|
const [showTotpField, setShowTotpField] = useState(false);
|
||||||
const [rememberMe, setRememberMe] = useState(false);
|
const [rememberMe, setRememberMe] = useState(false);
|
||||||
@@ -157,6 +177,27 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
}, [serverUrl, jmapEndpoint]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
if (sessionStorage.getItem('session_expired') === 'true') {
|
if (sessionStorage.getItem('session_expired') === 'true') {
|
||||||
@@ -254,7 +295,9 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!oauthEnabled || !serverUrl) return;
|
if (!oauthEnabled || !serverUrl) return;
|
||||||
discoverOAuth(oauthIssuerUrl || serverUrl)
|
setOauthDiscoveryDone(false);
|
||||||
|
setOauthMetadata(null);
|
||||||
|
discoverOAuth(effectiveOauthIssuerUrl || serverUrl)
|
||||||
.then((metadata) => {
|
.then((metadata) => {
|
||||||
setOauthMetadata(metadata);
|
setOauthMetadata(metadata);
|
||||||
setOauthDiscoveryDone(true);
|
setOauthDiscoveryDone(true);
|
||||||
@@ -263,7 +306,7 @@ export default function LoginPage() {
|
|||||||
setOauthMetadata(null);
|
setOauthMetadata(null);
|
||||||
setOauthDiscoveryDone(true);
|
setOauthDiscoveryDone(true);
|
||||||
});
|
});
|
||||||
}, [oauthEnabled, serverUrl, oauthIssuerUrl]);
|
}, [oauthEnabled, serverUrl, effectiveOauthIssuerUrl]);
|
||||||
|
|
||||||
// Auto-SSO: when enabled with OAUTH_ONLY, skip the login page entirely
|
// Auto-SSO: when enabled with OAUTH_ONLY, skip the login page entirely
|
||||||
const ssoError = searchParams.get("sso_error");
|
const ssoError = searchParams.get("sso_error");
|
||||||
@@ -278,7 +321,11 @@ export default function LoginPage() {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
credentials: 'include',
|
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) {
|
if (!res.ok) {
|
||||||
@@ -304,7 +351,7 @@ export default function LoginPage() {
|
|||||||
} catch {
|
} catch {
|
||||||
setOauthLoading(false);
|
setOauthLoading(false);
|
||||||
}
|
}
|
||||||
}, [params.locale]);
|
}, [params.locale, selectedServer?.id]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!autoSsoEnabled || !oauthOnly || !oauthDiscoveryDone || !oauthMetadata) return;
|
if (!autoSsoEnabled || !oauthOnly || !oauthDiscoveryDone || !oauthMetadata) return;
|
||||||
@@ -445,7 +492,7 @@ export default function LoginPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleOAuthLogin = async () => {
|
const handleOAuthLogin = async () => {
|
||||||
if (!oauthMetadata || !oauthClientId) return;
|
if (!oauthMetadata || !effectiveOauthClientId) return;
|
||||||
setOauthLoading(true);
|
setOauthLoading(true);
|
||||||
|
|
||||||
const verifier = generateCodeVerifier();
|
const verifier = generateCodeVerifier();
|
||||||
@@ -454,9 +501,19 @@ export default function LoginPage() {
|
|||||||
const prefix = getPathPrefix(params.locale as string);
|
const prefix = getPathPrefix(params.locale as string);
|
||||||
const redirectUri = `${window.location.origin}${prefix}/${params.locale}/auth/callback`;
|
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_code_verifier", verifier);
|
||||||
sessionStorage.setItem("oauth_state", state);
|
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) {
|
if (isAddAccountMode) {
|
||||||
sessionStorage.setItem("oauth_add_account_mode", "true");
|
sessionStorage.setItem("oauth_add_account_mode", "true");
|
||||||
}
|
}
|
||||||
@@ -473,7 +530,7 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
const authUrl = new URL(oauthMetadata.authorization_endpoint);
|
const authUrl = new URL(oauthMetadata.authorization_endpoint);
|
||||||
authUrl.searchParams.set("response_type", "code");
|
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("redirect_uri", redirectUri);
|
||||||
authUrl.searchParams.set("scope", OAUTH_SCOPES);
|
authUrl.searchParams.set("scope", OAUTH_SCOPES);
|
||||||
authUrl.searchParams.set("state", state);
|
authUrl.searchParams.set("state", state);
|
||||||
@@ -486,7 +543,10 @@ export default function LoginPage() {
|
|||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
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(
|
const success = await login(
|
||||||
effectiveServerUrl,
|
effectiveServerUrl,
|
||||||
formData.username,
|
formData.username,
|
||||||
@@ -607,13 +667,17 @@ export default function LoginPage() {
|
|||||||
<div className="px-8 pb-10 pt-4">
|
<div className="px-8 pb-10 pt-4">
|
||||||
{error && (
|
{error && (
|
||||||
<div className={cn(
|
<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"
|
shakeError && "animate-shake"
|
||||||
)}>
|
)}>
|
||||||
<AlertCircle className="w-4.5 h-4.5 text-red-500 flex-shrink-0 mt-0.5" />
|
<div className="w-10 h-10 rounded-full bg-destructive/15 text-destructive flex items-center justify-center flex-shrink-0 shadow-sm">
|
||||||
<p className="text-sm text-red-600 dark:text-red-400 leading-relaxed">
|
<AlertCircle className="w-5 h-5" />
|
||||||
{t(`error.${error}`) || t("error.generic")}
|
</div>
|
||||||
</p>
|
<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>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -754,37 +818,45 @@ export default function LoginPage() {
|
|||||||
{/* Session Expired Banner */}
|
{/* Session Expired Banner */}
|
||||||
{sessionExpired && (
|
{sessionExpired && (
|
||||||
<div
|
<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"
|
role="status"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
>
|
>
|
||||||
<Info className="w-4.5 h-4.5 text-blue-600 dark:text-blue-400 flex-shrink-0 mt-0.5" />
|
<div className="w-10 h-10 rounded-full bg-info/15 text-info flex items-center justify-center flex-shrink-0 shadow-sm">
|
||||||
<p className="text-sm text-blue-700 dark:text-blue-300 flex-1 leading-relaxed">
|
<Info className="w-5 h-5" />
|
||||||
{t("session_expired")}
|
</div>
|
||||||
</p>
|
<div className="flex-1 min-w-0 self-center flex items-center gap-2">
|
||||||
<button
|
<p className="text-sm text-info flex-1 leading-relaxed">
|
||||||
type="button"
|
{t("session_expired")}
|
||||||
onClick={() => setSessionExpired(false)}
|
</p>
|
||||||
className="p-0.5 rounded-md hover:bg-blue-500/10 transition-colors flex-shrink-0"
|
<button
|
||||||
aria-label={t("dismiss")}
|
type="button"
|
||||||
>
|
onClick={() => setSessionExpired(false)}
|
||||||
<X className="w-4 h-4 text-blue-600 dark:text-blue-400" />
|
className="p-1 rounded-md text-info hover:bg-info/10 transition-colors flex-shrink-0"
|
||||||
</button>
|
aria-label={t("dismiss")}
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Error Message */}
|
{/* Error Message */}
|
||||||
{error && (
|
{error && (
|
||||||
<div className={cn(
|
<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"
|
shakeError && "animate-shake"
|
||||||
)}>
|
)}>
|
||||||
<AlertCircle className="w-4.5 h-4.5 text-red-500 flex-shrink-0 mt-0.5" />
|
<div className="w-10 h-10 rounded-full bg-destructive/15 text-destructive flex items-center justify-center flex-shrink-0 shadow-sm">
|
||||||
<p className="text-sm text-red-600 dark:text-red-400 leading-relaxed">
|
<AlertCircle className="w-5 h-5" />
|
||||||
{error === 'invalid_credentials' && showTotpField && totpCode
|
</div>
|
||||||
? t('error.totp_invalid')
|
<div className="flex-1 min-w-0 self-center">
|
||||||
: t(`error.${error}`) || t("error.generic")}
|
<p className="text-sm text-destructive leading-relaxed">
|
||||||
</p>
|
{error === 'invalid_credentials' && showTotpField && totpCode
|
||||||
|
? t('error.totp_invalid')
|
||||||
|
: t(`error.${error}`) || t("error.generic")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -836,11 +908,15 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
) : oauthDiscoveryDone ? (
|
) : oauthDiscoveryDone ? (
|
||||||
<div className="p-3.5 bg-warning/10 border border-warning/20 rounded-xl flex items-start gap-2">
|
<div className="p-3 rounded-xl border border-warning/20 bg-warning/5 flex items-start gap-3">
|
||||||
<AlertCircle className="w-4 h-4 text-warning flex-shrink-0 mt-0.5" />
|
<div className="w-10 h-10 rounded-full bg-warning/15 text-warning flex items-center justify-center flex-shrink-0 shadow-sm">
|
||||||
<p className="text-sm text-warning">
|
<AlertCircle className="w-5 h-5" />
|
||||||
{t("error.oauth_discovery_failed")}
|
</div>
|
||||||
</p>
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex justify-center py-4">
|
<div className="flex justify-center py-4">
|
||||||
@@ -852,8 +928,32 @@ export default function LoginPage() {
|
|||||||
/* Login Form */
|
/* Login Form */
|
||||||
<form onSubmit={handleSubmit} className="space-y-5">
|
<form onSubmit={handleSubmit} className="space-y-5">
|
||||||
<fieldset disabled={isLoading} className="space-y-4">
|
<fieldset disabled={isLoading} className="space-y-4">
|
||||||
{/* JMAP Endpoint field (when custom endpoints are allowed) */}
|
{/* Server picker (when admin has configured a server list) */}
|
||||||
{allowCustomJmapEndpoint && (
|
{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">
|
<div className="space-y-1.5">
|
||||||
<label htmlFor="jmap-endpoint" className="block text-sm font-medium text-foreground">
|
<label htmlFor="jmap-endpoint" className="block text-sm font-medium text-foreground">
|
||||||
{t("jmap_endpoint_label")}
|
{t("jmap_endpoint_label")}
|
||||||
@@ -1068,11 +1168,15 @@ export default function LoginPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{oauthEnabled && oauthDiscoveryDone && !oauthMetadata && (
|
{oauthEnabled && oauthDiscoveryDone && !oauthMetadata && (
|
||||||
<div className="mt-2 p-3 bg-warning/10 border border-warning/20 rounded-xl flex items-start gap-2">
|
<div className="mt-2 p-3 rounded-xl border border-warning/20 bg-warning/5 flex items-start gap-3">
|
||||||
<AlertCircle className="w-4 h-4 text-warning flex-shrink-0 mt-0.5" />
|
<div className="w-10 h-10 rounded-full bg-warning/15 text-warning flex items-center justify-center flex-shrink-0 shadow-sm">
|
||||||
<p className="text-sm text-warning">
|
<AlertCircle className="w-5 h-5" />
|
||||||
{t("error.oauth_discovery_failed")}
|
</div>
|
||||||
</p>
|
<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>
|
||||||
)}
|
)}
|
||||||
</form>
|
</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 { useEffect, useState } from 'react';
|
||||||
import { Save, RotateCcw, Loader2 } from 'lucide-react';
|
import { Save, RotateCcw, Loader2 } from 'lucide-react';
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
import { JmapServersSection } from './_jmap-servers-section';
|
||||||
|
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
|
||||||
|
|
||||||
interface ConfigEntry {
|
interface ConfigEntry {
|
||||||
value: unknown;
|
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} />
|
<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>
|
||||||
|
|
||||||
|
<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">
|
<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 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} />
|
<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 { requireAdminAuth, getClientIP } from '@/lib/admin/session';
|
||||||
import { auditLog } from '@/lib/admin/audit';
|
import { auditLog } from '@/lib/admin/audit';
|
||||||
import { CONFIG_ENV_MAP } from '@/lib/admin/types';
|
import { CONFIG_ENV_MAP } from '@/lib/admin/types';
|
||||||
|
import { parseJmapServers } from '@/lib/admin/jmap-servers';
|
||||||
import { logger } from '@/lib/logger';
|
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 });
|
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
|
// Get old values for audit
|
||||||
const oldValues: Record<string, unknown> = {};
|
const oldValues: Record<string, unknown> = {};
|
||||||
for (const key of Object.keys(updates)) {
|
for (const key of Object.keys(updates)) {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||||
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||||
|
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
|
||||||
|
|
||||||
const COOKIE_OPTIONS = {
|
const COOKIE_OPTIONS = {
|
||||||
...getCookieOptions(),
|
...getCookieOptions(),
|
||||||
@@ -39,10 +40,11 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pin the upstream URL to the configured JMAP server so an unauthenticated
|
// Pin the upstream URL to a configured JMAP server so an unauthenticated
|
||||||
// caller cannot point this route at internal hosts. Only when no server URL
|
// caller cannot point this route at internal hosts. We accept the global
|
||||||
// is configured AND the deployment explicitly allows custom JMAP endpoints
|
// `jmapServerUrl` and any entry from `jmapServers`. When neither matches,
|
||||||
// do we honor the body URL — and even then it must be a public URL.
|
// 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();
|
await configManager.ensureLoaded();
|
||||||
const configuredServerUrl =
|
const configuredServerUrl =
|
||||||
configManager.get<string>('jmapServerUrl', '') ||
|
configManager.get<string>('jmapServerUrl', '') ||
|
||||||
@@ -50,11 +52,13 @@ export async function POST(request: NextRequest) {
|
|||||||
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
|
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
|
||||||
'';
|
'';
|
||||||
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
|
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
|
||||||
|
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
|
||||||
|
const trustedUrl = resolveTrustedJmapUrl(serverUrl, configuredServerUrl, serverList);
|
||||||
|
|
||||||
let upstreamUrl: string;
|
let upstreamUrl: string;
|
||||||
let upstreamTrusted: boolean;
|
let upstreamTrusted: boolean;
|
||||||
if (configuredServerUrl) {
|
if (trustedUrl) {
|
||||||
upstreamUrl = configuredServerUrl;
|
upstreamUrl = trustedUrl;
|
||||||
upstreamTrusted = true;
|
upstreamTrusted = true;
|
||||||
} else if (allowCustomEndpoint) {
|
} else if (allowCustomEndpoint) {
|
||||||
if (!(await isPublicHttpUrl(serverUrl))) {
|
if (!(await isPublicHttpUrl(serverUrl))) {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { cookies } from 'next/headers';
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { decryptPayload } from '@/lib/auth/crypto';
|
import { decryptPayload } from '@/lib/auth/crypto';
|
||||||
import { exchangeCodeForTokens } from '@/lib/oauth/token-exchange';
|
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';
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
|
|
||||||
const SSO_PENDING_COOKIE = 'sso_pending';
|
const SSO_PENDING_COOKIE = 'sso_pending';
|
||||||
@@ -55,6 +55,7 @@ export async function POST(request: NextRequest) {
|
|||||||
|
|
||||||
const codeVerifier = pending.code_verifier as string;
|
const codeVerifier = pending.code_verifier as string;
|
||||||
const redirectUri = pending.redirect_uri as string;
|
const redirectUri = pending.redirect_uri as string;
|
||||||
|
const pendingServerId = typeof pending.server_id === 'string' ? pending.server_id : null;
|
||||||
|
|
||||||
if (!codeVerifier || !redirectUri) {
|
if (!codeVerifier || !redirectUri) {
|
||||||
cookieStore.delete(SSO_PENDING_COOKIE);
|
cookieStore.delete(SSO_PENDING_COOKIE);
|
||||||
@@ -62,13 +63,19 @@ export async function POST(request: NextRequest) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Exchange code for tokens
|
// 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.
|
// Store refresh token in the per-account cookie slot.
|
||||||
if (tokens.refresh_token) {
|
if (tokens.refresh_token) {
|
||||||
const cookieName = refreshTokenCookieName(slot);
|
const cookieName = refreshTokenCookieName(slot);
|
||||||
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
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
|
// Delete pending cookie
|
||||||
cookieStore.delete(SSO_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 });
|
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') {
|
if (!redirect_uri || typeof redirect_uri !== 'string') {
|
||||||
return NextResponse.json({ error: 'Missing redirect_uri' }, { status: 400 });
|
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
|
// Validate redirect_uri origin matches the request origin to prevent open redirects
|
||||||
const requestOrigin = request.headers.get('origin') || request.nextUrl.origin;
|
const requestOrigin = request.headers.get('origin') || request.nextUrl.origin;
|
||||||
try {
|
try {
|
||||||
@@ -36,7 +38,7 @@ export async function POST(request: NextRequest) {
|
|||||||
return NextResponse.json({ error: 'Invalid redirect_uri' }, { status: 400 });
|
return NextResponse.json({ error: 'Invalid redirect_uri' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { clientId, discoveryUrl } = getRequiredConfig();
|
const { clientId, discoveryUrl } = getRequiredConfig(serverId);
|
||||||
const metadata = await discoverOAuth(discoveryUrl);
|
const metadata = await discoverOAuth(discoveryUrl);
|
||||||
|
|
||||||
if (!metadata?.authorization_endpoint) {
|
if (!metadata?.authorization_endpoint) {
|
||||||
@@ -48,12 +50,14 @@ export async function POST(request: NextRequest) {
|
|||||||
const codeChallenge = generateCodeChallengeServer(codeVerifier);
|
const codeChallenge = generateCodeChallengeServer(codeVerifier);
|
||||||
const state = generateStateServer();
|
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 = {
|
const pendingData = {
|
||||||
state,
|
state,
|
||||||
code_verifier: codeVerifier,
|
code_verifier: codeVerifier,
|
||||||
redirect_uri,
|
redirect_uri,
|
||||||
created_at: Date.now(),
|
created_at: Date.now(),
|
||||||
|
...(serverId ? { server_id: serverId } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
const encrypted = encryptPayload(pendingData);
|
const encrypted = encryptPayload(pendingData);
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { setStalwartAuthContext } from '@/lib/stalwart/auth-context';
|
|||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||||
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
||||||
|
import { parseJmapServers, resolveTrustedJmapUrl } from '@/lib/admin/jmap-servers';
|
||||||
|
|
||||||
function getSlot(request: NextRequest, bodySlot: unknown): number {
|
function getSlot(request: NextRequest, bodySlot: unknown): number {
|
||||||
if (typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4) {
|
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 });
|
return NextResponse.json({ error: 'Missing required fields' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Pin the upstream URL to the configured JMAP server so an unauthenticated
|
// Pin the upstream URL to a configured JMAP server (single `jmapServerUrl`
|
||||||
// caller cannot point this route at internal hosts. Only when no server URL
|
// or any entry in `jmapServers`). Falls back to the request URL only when
|
||||||
// is configured AND the deployment explicitly allows custom JMAP endpoints
|
// `allowCustomJmapEndpoint` is enabled, and even then it must be public.
|
||||||
// do we honor the body URL — and even then it must be a public URL.
|
|
||||||
await configManager.ensureLoaded();
|
await configManager.ensureLoaded();
|
||||||
const configuredServerUrl =
|
const configuredServerUrl =
|
||||||
configManager.get<string>('jmapServerUrl', '') ||
|
configManager.get<string>('jmapServerUrl', '') ||
|
||||||
@@ -37,11 +37,13 @@ export async function POST(request: NextRequest) {
|
|||||||
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
|
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
|
||||||
'';
|
'';
|
||||||
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
|
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
|
||||||
|
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
|
||||||
|
const trustedUrl = resolveTrustedJmapUrl(serverUrl, configuredServerUrl, serverList);
|
||||||
|
|
||||||
let upstreamUrl: string;
|
let upstreamUrl: string;
|
||||||
let upstreamTrusted: boolean;
|
let upstreamTrusted: boolean;
|
||||||
if (configuredServerUrl) {
|
if (trustedUrl) {
|
||||||
upstreamUrl = configuredServerUrl;
|
upstreamUrl = trustedUrl;
|
||||||
upstreamTrusted = true;
|
upstreamTrusted = true;
|
||||||
} else if (allowCustomEndpoint) {
|
} else if (allowCustomEndpoint) {
|
||||||
if (!(await isPublicHttpUrl(serverUrl))) {
|
if (!(await isPublicHttpUrl(serverUrl))) {
|
||||||
|
|||||||
+27
-10
@@ -1,7 +1,7 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server';
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { logger } from '@/lib/logger';
|
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 { exchangeCodeForTokens, buildOAuthParams, getMetadata, getTokenEndpoint } from '@/lib/oauth/token-exchange';
|
||||||
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
import { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
|
|
||||||
@@ -15,26 +15,36 @@ function getSlot(request: NextRequest): number {
|
|||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
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) {
|
if (!code || !code_verifier || !redirect_uri) {
|
||||||
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : getSlot(request);
|
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({
|
const response = NextResponse.json({
|
||||||
access_token: tokens.access_token,
|
access_token: tokens.access_token,
|
||||||
expires_in: tokens.expires_in,
|
expires_in: tokens.expires_in,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const cookieStore = await cookies();
|
||||||
if (tokens.refresh_token) {
|
if (tokens.refresh_token) {
|
||||||
const cookieName = refreshTokenCookieName(slot);
|
const cookieName = refreshTokenCookieName(slot);
|
||||||
const cookieStore = await cookies();
|
|
||||||
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
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;
|
return response;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -49,17 +59,18 @@ export async function PUT(request: NextRequest) {
|
|||||||
const cookieName = refreshTokenCookieName(slot);
|
const cookieName = refreshTokenCookieName(slot);
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const refreshToken = cookieStore.get(cookieName)?.value;
|
const refreshToken = cookieStore.get(cookieName)?.value;
|
||||||
|
const serverId = cookieStore.get(refreshTokenServerCookieName(slot))?.value || null;
|
||||||
|
|
||||||
if (!refreshToken) {
|
if (!refreshToken) {
|
||||||
return NextResponse.json({ error: 'No refresh token' }, { status: 401 });
|
return NextResponse.json({ error: 'No refresh token' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const tokenEndpoint = await getTokenEndpoint();
|
const tokenEndpoint = await getTokenEndpoint(serverId);
|
||||||
|
|
||||||
const params = buildOAuthParams({
|
const params = buildOAuthParams({
|
||||||
grant_type: 'refresh_token',
|
grant_type: 'refresh_token',
|
||||||
refresh_token: refreshToken,
|
refresh_token: refreshToken,
|
||||||
});
|
}, serverId);
|
||||||
|
|
||||||
const tokenResponse = await fetch(tokenEndpoint, {
|
const tokenResponse = await fetch(tokenEndpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -71,6 +82,7 @@ export async function PUT(request: NextRequest) {
|
|||||||
const errorText = await tokenResponse.text();
|
const errorText = await tokenResponse.text();
|
||||||
logger.error('Token refresh failed', { status: tokenResponse.status, error: errorText });
|
logger.error('Token refresh failed', { status: tokenResponse.status, error: errorText });
|
||||||
cookieStore.delete(cookieName);
|
cookieStore.delete(cookieName);
|
||||||
|
cookieStore.delete(refreshTokenServerCookieName(slot));
|
||||||
return NextResponse.json({ error: 'Refresh failed' }, { status: 401 });
|
return NextResponse.json({ error: 'Refresh failed' }, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,13 +116,15 @@ export async function DELETE(request: NextRequest) {
|
|||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
for (let i = 0; i <= 4; i++) {
|
for (let i = 0; i <= 4; i++) {
|
||||||
const name = refreshTokenCookieName(i);
|
const name = refreshTokenCookieName(i);
|
||||||
|
const serverCookieName = refreshTokenServerCookieName(i);
|
||||||
const token = cookieStore.get(name)?.value;
|
const token = cookieStore.get(name)?.value;
|
||||||
|
const slotServerId = cookieStore.get(serverCookieName)?.value || null;
|
||||||
if (token) {
|
if (token) {
|
||||||
// Best-effort revocation
|
// Best-effort revocation
|
||||||
try {
|
try {
|
||||||
const metadata = await getMetadata().catch(() => null);
|
const metadata = await getMetadata(slotServerId).catch(() => null);
|
||||||
if (metadata?.revocation_endpoint) {
|
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, {
|
await fetch(metadata.revocation_endpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
@@ -120,6 +134,7 @@ export async function DELETE(request: NextRequest) {
|
|||||||
} catch { /* best effort */ }
|
} catch { /* best effort */ }
|
||||||
cookieStore.delete(name);
|
cookieStore.delete(name);
|
||||||
}
|
}
|
||||||
|
cookieStore.delete(serverCookieName);
|
||||||
}
|
}
|
||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
@@ -128,7 +143,8 @@ export async function DELETE(request: NextRequest) {
|
|||||||
const cookieName = refreshTokenCookieName(slot);
|
const cookieName = refreshTokenCookieName(slot);
|
||||||
const cookieStore = await cookies();
|
const cookieStore = await cookies();
|
||||||
const refreshToken = cookieStore.get(cookieName)?.value;
|
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', {
|
logger.warn('Failed to discover OAuth metadata during logout', {
|
||||||
error: err instanceof Error ? err.message : 'Unknown error',
|
error: err instanceof Error ? err.message : 'Unknown error',
|
||||||
});
|
});
|
||||||
@@ -140,7 +156,7 @@ export async function DELETE(request: NextRequest) {
|
|||||||
const params = buildOAuthParams({
|
const params = buildOAuthParams({
|
||||||
token: refreshToken,
|
token: refreshToken,
|
||||||
token_type_hint: 'refresh_token',
|
token_type_hint: 'refresh_token',
|
||||||
});
|
}, slotServerId);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const revocationResponse = await fetch(metadata.revocation_endpoint, {
|
const revocationResponse = await fetch(metadata.revocation_endpoint, {
|
||||||
@@ -158,6 +174,7 @@ export async function DELETE(request: NextRequest) {
|
|||||||
|
|
||||||
cookieStore.delete(cookieName);
|
cookieStore.delete(cookieName);
|
||||||
}
|
}
|
||||||
|
cookieStore.delete(refreshTokenServerCookieName(slot));
|
||||||
|
|
||||||
let end_session_url: string | undefined;
|
let end_session_url: string | undefined;
|
||||||
if (metadata?.end_session_endpoint) {
|
if (metadata?.end_session_endpoint) {
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import { NextRequest, NextResponse } from 'next/server';
|
|||||||
import { cookies } from 'next/headers';
|
import { cookies } from 'next/headers';
|
||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { discoverOAuth } from '@/lib/oauth/discovery';
|
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 { getCookieOptions } from '@/lib/oauth/cookie-config';
|
||||||
import { readFileEnv } from '@/lib/read-file-env';
|
import { readFileEnv } from '@/lib/read-file-env';
|
||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
import { isPublicHttpUrl } from '@/lib/security/url-guard';
|
||||||
import { recordLogin } from '@/lib/telemetry/login-tracker';
|
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.
|
* 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) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
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) {
|
if (!serverUrl || !username || !password) {
|
||||||
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
|
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const slot = typeof bodySlot === 'number' && bodySlot >= 0 && bodySlot <= 4 ? bodySlot : 0;
|
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
|
// Pin the upstream URL to a configured JMAP server. The list of allowed
|
||||||
// caller cannot point this route at internal hosts. Only when no server
|
// servers is `jmapServerUrl` plus any entry from `jmapServers`. Only when
|
||||||
// URL is configured (and the deployment explicitly allows custom JMAP
|
// no server is configured (and the deployment explicitly allows custom
|
||||||
// endpoints) do we fall back to the user-supplied URL - and even then
|
// JMAP endpoints) do we fall back to the user-supplied URL — and even then
|
||||||
// it must resolve to a public address.
|
// it must resolve to a public address.
|
||||||
await configManager.ensureLoaded();
|
await configManager.ensureLoaded();
|
||||||
const configuredServerUrl =
|
const configuredServerUrl =
|
||||||
@@ -98,9 +100,17 @@ export async function POST(request: NextRequest) {
|
|||||||
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
|
process.env.NEXT_PUBLIC_JMAP_SERVER_URL ||
|
||||||
'';
|
'';
|
||||||
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
|
const allowCustomEndpoint = configManager.get<boolean>('allowCustomJmapEndpoint', false);
|
||||||
|
const serverList = parseJmapServers(configManager.get<unknown>('jmapServers', []));
|
||||||
|
|
||||||
let upstreamUrl: string;
|
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;
|
upstreamUrl = configuredServerUrl;
|
||||||
} else if (allowCustomEndpoint) {
|
} else if (allowCustomEndpoint) {
|
||||||
if (!(await isPublicHttpUrl(serverUrl))) {
|
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 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) {
|
} catch (error) {
|
||||||
logger.error('TOTP token exchange error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
logger.error('TOTP token exchange error', { error: error instanceof Error ? error.message : 'Unknown error' });
|
||||||
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
|
||||||
@@ -131,11 +141,21 @@ async function attemptAllStrategies(
|
|||||||
username: string,
|
username: string,
|
||||||
password: string,
|
password: string,
|
||||||
slot: number,
|
slot: number,
|
||||||
|
serverId: string | null,
|
||||||
): Promise<NextResponse> {
|
): Promise<NextResponse> {
|
||||||
logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint });
|
logger.info('TOTP token exchange: found token endpoint', { tokenEndpoint });
|
||||||
|
|
||||||
const clientId = configManager.get<string>('oauthClientId', '') || process.env.OAUTH_CLIENT_ID;
|
// Per-server OAuth credentials override the global ones when the requested
|
||||||
const clientSecret = configManager.get<string>('oauthClientSecret', '') || process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE);
|
// 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 basicAuth = `Basic ${Buffer.from(`${username}:${password}`).toString('base64')}`;
|
||||||
const attempts: Array<{ strategy: string; error: string }> = [];
|
const attempts: Array<{ strategy: string; error: string }> = [];
|
||||||
|
|
||||||
@@ -147,7 +167,7 @@ async function attemptAllStrategies(
|
|||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
logger.info('TOTP token exchange succeeded (ROPC with client_id)');
|
logger.info('TOTP token exchange succeeded (ROPC with client_id)');
|
||||||
void recordLogin(username, serverUrl);
|
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 });
|
attempts.push({ strategy: 'ROPC with client_id', error: result.error });
|
||||||
}
|
}
|
||||||
@@ -159,7 +179,7 @@ async function attemptAllStrategies(
|
|||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
logger.info('TOTP token exchange succeeded (ROPC without client_id)');
|
logger.info('TOTP token exchange succeeded (ROPC without client_id)');
|
||||||
void recordLogin(username, serverUrl);
|
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 });
|
attempts.push({ strategy: 'ROPC without client_id', error: result.error });
|
||||||
}
|
}
|
||||||
@@ -171,7 +191,7 @@ async function attemptAllStrategies(
|
|||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
logger.info('TOTP token exchange succeeded (Basic Auth header)');
|
logger.info('TOTP token exchange succeeded (Basic Auth header)');
|
||||||
void recordLogin(username, serverUrl);
|
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 });
|
attempts.push({ strategy: 'Basic Auth header', error: result.error });
|
||||||
}
|
}
|
||||||
@@ -183,7 +203,7 @@ async function attemptAllStrategies(
|
|||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
logger.info('TOTP token exchange succeeded (client_credentials + Basic Auth)');
|
logger.info('TOTP token exchange succeeded (client_credentials + Basic Auth)');
|
||||||
void recordLogin(username, serverUrl);
|
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 });
|
attempts.push({ strategy: 'client_credentials + Basic Auth', error: result.error });
|
||||||
}
|
}
|
||||||
@@ -199,12 +219,19 @@ async function attemptAllStrategies(
|
|||||||
async function storeAndRespond(
|
async function storeAndRespond(
|
||||||
tokens: { access_token: string; expires_in?: number; refresh_token?: string },
|
tokens: { access_token: string; expires_in?: number; refresh_token?: string },
|
||||||
slot: number,
|
slot: number,
|
||||||
|
serverId: string | null,
|
||||||
): Promise<NextResponse> {
|
): Promise<NextResponse> {
|
||||||
|
const cookieStore = await cookies();
|
||||||
if (tokens.refresh_token) {
|
if (tokens.refresh_token) {
|
||||||
const cookieName = refreshTokenCookieName(slot);
|
const cookieName = refreshTokenCookieName(slot);
|
||||||
const cookieStore = await cookies();
|
|
||||||
cookieStore.set(cookieName, tokens.refresh_token, getCookieOptions());
|
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({
|
return NextResponse.json({
|
||||||
access_token: tokens.access_token,
|
access_token: tokens.access_token,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
|
|||||||
import { logger } from '@/lib/logger';
|
import { logger } from '@/lib/logger';
|
||||||
import { configManager } from '@/lib/admin/config-manager';
|
import { configManager } from '@/lib/admin/config-manager';
|
||||||
import { readFileEnv } from '@/lib/read-file-env';
|
import { readFileEnv } from '@/lib/read-file-env';
|
||||||
|
import { parseJmapServers, redactJmapServers } from '@/lib/admin/jmap-servers';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Runtime configuration endpoint
|
* Runtime configuration endpoint
|
||||||
@@ -49,6 +50,8 @@ export async function GET() {
|
|||||||
loginWebsiteUrl: configManager.get<string>('loginWebsiteUrl', ''),
|
loginWebsiteUrl: configManager.get<string>('loginWebsiteUrl', ''),
|
||||||
demoMode: configManager.get<boolean>('demoMode', false),
|
demoMode: configManager.get<boolean>('demoMode', false),
|
||||||
allowCustomJmapEndpoint: configManager.get<boolean>('allowCustomJmapEndpoint', 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),
|
autoSsoEnabled: configManager.get<boolean>('autoSsoEnabled', false),
|
||||||
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
|
embeddedMode: !!allowedFrameAncestors && allowedFrameAncestors !== "'none'",
|
||||||
parentOrigin: configManager.get<string>('parentOrigin', ''),
|
parentOrigin: configManager.get<string>('parentOrigin', ''),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { usePolicyStore } from '@/stores/policy-store';
|
import { usePolicyStore } from '@/stores/policy-store';
|
||||||
import { apiFetch } from '@/lib/browser-navigation';
|
import { apiFetch } from '@/lib/browser-navigation';
|
||||||
|
import type { PublicJmapServerEntry } from '@/lib/admin/jmap-servers';
|
||||||
|
|
||||||
interface ConfigData {
|
interface ConfigData {
|
||||||
appName: string;
|
appName: string;
|
||||||
@@ -27,6 +28,8 @@ interface ConfigData {
|
|||||||
demoMode: boolean;
|
demoMode: boolean;
|
||||||
autoSsoEnabled: boolean;
|
autoSsoEnabled: boolean;
|
||||||
allowCustomJmapEndpoint: boolean;
|
allowCustomJmapEndpoint: boolean;
|
||||||
|
jmapServers: PublicJmapServerEntry[];
|
||||||
|
jmapServerAutoPickByDomain: boolean;
|
||||||
embeddedMode: boolean;
|
embeddedMode: boolean;
|
||||||
parentOrigin: string;
|
parentOrigin: string;
|
||||||
}
|
}
|
||||||
@@ -103,6 +106,8 @@ export function useConfig(): AppConfig {
|
|||||||
demoMode: configCache?.demoMode || false,
|
demoMode: configCache?.demoMode || false,
|
||||||
autoSsoEnabled: configCache?.autoSsoEnabled || false,
|
autoSsoEnabled: configCache?.autoSsoEnabled || false,
|
||||||
allowCustomJmapEndpoint: configCache?.allowCustomJmapEndpoint || false,
|
allowCustomJmapEndpoint: configCache?.allowCustomJmapEndpoint || false,
|
||||||
|
jmapServers: configCache?.jmapServers || [],
|
||||||
|
jmapServerAutoPickByDomain: configCache?.jmapServerAutoPickByDomain || false,
|
||||||
embeddedMode: configCache?.embeddedMode || false,
|
embeddedMode: configCache?.embeddedMode || false,
|
||||||
parentOrigin: configCache?.parentOrigin || '',
|
parentOrigin: configCache?.parentOrigin || '',
|
||||||
isLoading: !configCache,
|
isLoading: !configCache,
|
||||||
@@ -135,6 +140,8 @@ export function useConfig(): AppConfig {
|
|||||||
demoMode: configCache.demoMode,
|
demoMode: configCache.demoMode,
|
||||||
autoSsoEnabled: configCache.autoSsoEnabled,
|
autoSsoEnabled: configCache.autoSsoEnabled,
|
||||||
allowCustomJmapEndpoint: configCache.allowCustomJmapEndpoint,
|
allowCustomJmapEndpoint: configCache.allowCustomJmapEndpoint,
|
||||||
|
jmapServers: configCache.jmapServers || [],
|
||||||
|
jmapServerAutoPickByDomain: configCache.jmapServerAutoPickByDomain || false,
|
||||||
embeddedMode: configCache.embeddedMode,
|
embeddedMode: configCache.embeddedMode,
|
||||||
parentOrigin: configCache.parentOrigin,
|
parentOrigin: configCache.parentOrigin,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
@@ -168,6 +175,8 @@ export function useConfig(): AppConfig {
|
|||||||
demoMode: data.demoMode,
|
demoMode: data.demoMode,
|
||||||
autoSsoEnabled: data.autoSsoEnabled,
|
autoSsoEnabled: data.autoSsoEnabled,
|
||||||
allowCustomJmapEndpoint: data.allowCustomJmapEndpoint,
|
allowCustomJmapEndpoint: data.allowCustomJmapEndpoint,
|
||||||
|
jmapServers: data.jmapServers || [],
|
||||||
|
jmapServerAutoPickByDomain: data.jmapServerAutoPickByDomain || false,
|
||||||
embeddedMode: data.embeddedMode,
|
embeddedMode: data.embeddedMode,
|
||||||
parentOrigin: data.parentOrigin,
|
parentOrigin: data.parentOrigin,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
|
|||||||
@@ -13,6 +13,12 @@ function parseEnvValue(value: string, type: string): unknown {
|
|||||||
switch (type) {
|
switch (type) {
|
||||||
case 'boolean':
|
case 'boolean':
|
||||||
return value === 'true';
|
return value === 'true';
|
||||||
|
case 'json':
|
||||||
|
try {
|
||||||
|
return JSON.parse(value);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
case 'string':
|
case 'string':
|
||||||
case 'url':
|
case 'url':
|
||||||
case 'enum':
|
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 */
|
/** 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' },
|
appName: { envVar: 'APP_NAME', type: 'string', defaultValue: 'Webmail' },
|
||||||
jmapServerUrl: { envVar: 'JMAP_SERVER_URL', type: 'url', defaultValue: '' },
|
jmapServerUrl: { envVar: 'JMAP_SERVER_URL', type: 'url', defaultValue: '' },
|
||||||
stalwartFeaturesEnabled: { envVar: 'STALWART_FEATURES', type: 'boolean', defaultValue: true },
|
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: '' },
|
oauthClientSecret: { envVar: 'OAUTH_CLIENT_SECRET', fileEnvVar: 'OAUTH_CLIENT_SECRET_FILE', type: 'string', defaultValue: '' },
|
||||||
oauthIssuerUrl: { envVar: 'OAUTH_ISSUER_URL', type: 'url', defaultValue: '' },
|
oauthIssuerUrl: { envVar: 'OAUTH_ISSUER_URL', type: 'url', defaultValue: '' },
|
||||||
allowCustomJmapEndpoint: { envVar: 'ALLOW_CUSTOM_JMAP_ENDPOINT', type: 'boolean', defaultValue: false },
|
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 },
|
autoSsoEnabled: { envVar: 'AUTO_SSO_ENABLED', type: 'boolean', defaultValue: false },
|
||||||
cookieSameSite: { envVar: 'COOKIE_SAME_SITE', type: 'enum', defaultValue: 'lax', enumValues: ['lax', 'strict', 'none'] },
|
cookieSameSite: { envVar: 'COOKIE_SAME_SITE', type: 'enum', defaultValue: 'lax', enumValues: ['lax', 'strict', 'none'] },
|
||||||
allowedFrameAncestors: { envVar: 'ALLOWED_FRAME_ANCESTORS', type: 'string', defaultValue: '' },
|
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 type { OAuthMetadata } from '@/lib/oauth/discovery';
|
||||||
import { readFileEnv } from '@/lib/read-file-env';
|
import { readFileEnv } from '@/lib/read-file-env';
|
||||||
import { configManager } from '@/lib/admin/config-manager';
|
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', '');
|
const adminSecret = configManager.get<string>('oauthClientSecret', '');
|
||||||
if (adminSecret) return adminSecret;
|
if (adminSecret) return adminSecret;
|
||||||
return process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE) || '';
|
return process.env.OAUTH_CLIENT_SECRET || readFileEnv(process.env.OAUTH_CLIENT_SECRET_FILE) || '';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getRequiredConfig() {
|
function getServerEntry(serverId?: string | null) {
|
||||||
const clientId = configManager.get<string>('oauthClientId', '') || process.env.OAUTH_CLIENT_ID;
|
if (!serverId) return undefined;
|
||||||
const serverUrl = configManager.get<string>('jmapServerUrl', '') || process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL;
|
const servers = parseJmapServers(configManager.get<unknown>('jmapServers', []));
|
||||||
const issuerUrl = configManager.get<string>('oauthIssuerUrl', '') || process.env.OAUTH_ISSUER_URL;
|
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) {
|
if (!clientId || !serverUrl) {
|
||||||
throw new Error(`OAuth misconfigured: ${[!clientId && 'OAUTH_CLIENT_ID', !serverUrl && 'JMAP_SERVER_URL'].filter(Boolean).join(', ')} not set`);
|
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()) {
|
if (issuerUrl !== undefined && issuerUrl !== '' && !issuerUrl.trim()) {
|
||||||
logger.warn('OAUTH_ISSUER_URL is set but empty, falling back to JMAP_SERVER_URL for discovery');
|
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> {
|
function getClientSecret(serverId?: string | null): string {
|
||||||
const { discoveryUrl } = getRequiredConfig();
|
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);
|
const metadata = await discoverOAuth(discoveryUrl);
|
||||||
if (!metadata?.token_endpoint) {
|
if (!metadata?.token_endpoint) {
|
||||||
throw new Error('OAuth token endpoint not found');
|
throw new Error('OAuth token endpoint not found');
|
||||||
@@ -33,15 +53,15 @@ export async function getTokenEndpoint(): Promise<string> {
|
|||||||
return metadata.token_endpoint;
|
return metadata.token_endpoint;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getMetadata(): Promise<OAuthMetadata | null> {
|
export async function getMetadata(serverId?: string | null): Promise<OAuthMetadata | null> {
|
||||||
const { discoveryUrl } = getRequiredConfig();
|
const { discoveryUrl } = getRequiredConfig(serverId);
|
||||||
return discoverOAuth(discoveryUrl);
|
return discoverOAuth(discoveryUrl);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildOAuthParams(base: Record<string, string>): URLSearchParams {
|
export function buildOAuthParams(base: Record<string, string>, serverId?: string | null): URLSearchParams {
|
||||||
const { clientId } = getRequiredConfig();
|
const { clientId } = getRequiredConfig(serverId);
|
||||||
const params = new URLSearchParams({ ...base, client_id: clientId });
|
const params = new URLSearchParams({ ...base, client_id: clientId });
|
||||||
const secret = getClientSecret();
|
const secret = getClientSecret(serverId);
|
||||||
if (secret) {
|
if (secret) {
|
||||||
params.set('client_secret', secret);
|
params.set('client_secret', secret);
|
||||||
}
|
}
|
||||||
@@ -58,15 +78,16 @@ export async function exchangeCodeForTokens(
|
|||||||
code: string,
|
code: string,
|
||||||
codeVerifier: string,
|
codeVerifier: string,
|
||||||
redirectUri: string,
|
redirectUri: string,
|
||||||
|
serverId?: string | null,
|
||||||
): Promise<TokenResult> {
|
): Promise<TokenResult> {
|
||||||
const tokenEndpoint = await getTokenEndpoint();
|
const tokenEndpoint = await getTokenEndpoint(serverId);
|
||||||
|
|
||||||
const params = buildOAuthParams({
|
const params = buildOAuthParams({
|
||||||
grant_type: 'authorization_code',
|
grant_type: 'authorization_code',
|
||||||
code,
|
code,
|
||||||
redirect_uri: redirectUri,
|
redirect_uri: redirectUri,
|
||||||
code_verifier: codeVerifier,
|
code_verifier: codeVerifier,
|
||||||
});
|
}, serverId);
|
||||||
|
|
||||||
const tokenResponse = await fetch(tokenEndpoint, {
|
const tokenResponse = await fetch(tokenEndpoint, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|||||||
@@ -2,8 +2,14 @@ const DEFAULT_SCOPES = 'openid email profile';
|
|||||||
const EXTRA_SCOPES = process.env.OAUTH_EXTRA_SCOPES || '';
|
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 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_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. */
|
/** Get the cookie name for a given account slot (0-4). Slot 0 uses the legacy name. */
|
||||||
export function refreshTokenCookieName(slot: number): string {
|
export function refreshTokenCookieName(slot: number): string {
|
||||||
return slot === 0 ? REFRESH_TOKEN_COOKIE : `${REFRESH_TOKEN_COOKIE}_${slot}`;
|
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_label": "JMAP Server",
|
||||||
"jmap_endpoint_placeholder": "https://mail.example.com",
|
"jmap_endpoint_placeholder": "https://mail.example.com",
|
||||||
"jmap_endpoint_cors_hint": "The server must allow CORS requests from this domain.",
|
"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",
|
"sign_in": "Sign in",
|
||||||
"signing_in": "Signing in...",
|
"signing_in": "Signing in...",
|
||||||
"loading": "Loading...",
|
"loading": "Loading...",
|
||||||
|
|||||||
+12
-3
@@ -37,7 +37,7 @@ interface AuthState {
|
|||||||
isDemoMode: boolean;
|
isDemoMode: boolean;
|
||||||
|
|
||||||
login: (serverUrl: string, username: string, password: string, totp?: string, rememberMe?: boolean) => Promise<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>;
|
loginWithServerSso: (code: string, state: string) => Promise<boolean>;
|
||||||
loginDemo: () => Promise<boolean>;
|
loginDemo: () => Promise<boolean>;
|
||||||
refreshAccessToken: () => Promise<string | null>;
|
refreshAccessToken: () => Promise<string | null>;
|
||||||
@@ -408,6 +408,9 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ serverUrl, username, password: effectivePassword, slot: cookieSlot }),
|
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) {
|
if (tokenRes.ok) {
|
||||||
const { access_token, expires_in, has_refresh_token } = await tokenRes.json();
|
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 });
|
set({ isLoading: true, error: null, isRateLimited: false, rateLimitUntil: null });
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -620,7 +623,13 @@ export const useAuthStore = create<AuthState>()(
|
|||||||
const tokenRes = await apiFetch(`/api/auth/token?slot=${slot}`, {
|
const tokenRes = await apiFetch(`/api/auth/token?slot=${slot}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
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) {
|
if (!tokenRes.ok) {
|
||||||
|
|||||||
Reference in New Issue
Block a user