feat: add OAuth2/OIDC with PKCE for SSO login

Add opt-in SSO authentication alongside Basic Auth. OAuth endpoints are
auto-discovered via .well-known, with support for external IdPs
(Keycloak, Authentik) via configurable OAUTH_ISSUER_URL. Sessions
persist through httpOnly refresh token cookies with automatic renewal.
This commit is contained in:
Matthieu MALVACHE
2026-02-25 23:41:37 +01:00
committed by Matthieu MALVACHE
parent 110dd98ad4
commit ec06b0c494
20 changed files with 857 additions and 92 deletions
+16
View File
@@ -117,6 +117,9 @@ This webmail client is designed to work seamlessly with [**Stalwart Mail Server*
- SPF/DKIM/DMARC status indicators
- No password storage (session-based auth)
- TOTP two-factor authentication support
- OAuth2/OIDC with PKCE for SSO login (opt-in, Basic Auth remains default)
- External IdP support (Keycloak, Authentik) via configurable issuer URL
- Session persistence via httpOnly refresh token cookies
- CORS misconfiguration detection with actionable error messages
- Shared folder support with proper permissions
- Newsletter unsubscribe support (RFC 2369)
@@ -179,6 +182,19 @@ JMAP_SERVER_URL=https://mail.example.com
**Note:** These are runtime environment variables, read at request time. This enables Docker deployments to be configured without rebuilding the image. Legacy `NEXT_PUBLIC_*` variables are still supported as fallbacks.
#### OAuth2/OIDC (optional)
To enable SSO login alongside Basic Auth:
```env
OAUTH_ENABLED=true
OAUTH_CLIENT_ID=webmail
OAUTH_CLIENT_SECRET= # optional, for confidential clients
OAUTH_ISSUER_URL= # optional, for external IdPs (Keycloak, Authentik)
```
OAuth endpoints are auto-discovered via `.well-known/oauth-authorization-server` or `.well-known/openid-configuration`. If your JMAP server delegates auth to an external IdP, set `OAUTH_ISSUER_URL` to the IdP's base URL (e.g., `https://keycloak.example.com/realms/mail`).
### Development
```bash
+3 -1
View File
@@ -18,6 +18,8 @@ This document tracks the development status and planned features for JMAP Webmai
- [x] Authentication error handling
- [x] JMAP identities for sender address
- [x] TOTP two-factor authentication (Stalwart-compatible)
- [x] OAuth2/OIDC with PKCE (opt-in SSO, session persistence via httpOnly refresh tokens)
- [x] External IdP support via explicit issuer URL (Keycloak, Authentik, etc.)
### JMAP Server Connection
- [x] Session establishment and keep-alive
@@ -235,7 +237,7 @@ This document tracks the development status and planned features for JMAP Webmai
- [ ] Free/busy queries (Principal/getAvailability)
- [ ] Calendar sharing UI (JMAP Sharing RFC 9670)
- [ ] Email encryption (PGP/GPG)
- [ ] OAuth2/OIDC authentication (opt-in, Basic Auth remains default)
- [ ] OAuth2 token introspection and userinfo endpoint support
### Performance Optimizations
- [ ] Email content caching
+114
View File
@@ -0,0 +1,114 @@
"use client";
import { Suspense, useEffect, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { useAuthStore } from "@/stores/auth-store";
import { Loader2, AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { useParams } from "next/navigation";
function OAuthCallbackInner() {
const router = useRouter();
const params = useParams();
const searchParams = useSearchParams();
const t = useTranslations("login");
const { loginWithOAuth } = useAuthStore();
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const code = searchParams.get("code");
const state = searchParams.get("state");
const errorParam = searchParams.get("error");
if (errorParam) {
setError(errorParam === "access_denied" ? "access_denied" : "token_exchange_failed");
return;
}
if (!code) {
setError("missing_params");
return;
}
const savedState = sessionStorage.getItem("oauth_state");
if (!state || state !== savedState) {
setError("invalid_state");
return;
}
const codeVerifier = sessionStorage.getItem("oauth_code_verifier");
const serverUrl = sessionStorage.getItem("oauth_server_url");
if (!codeVerifier || !serverUrl) {
setError("missing_params");
return;
}
const redirectUri = `${window.location.origin}/${params.locale}/auth/callback`;
loginWithOAuth(serverUrl, code, codeVerifier, redirectUri)
.then((success) => {
if (success) {
sessionStorage.removeItem("oauth_state");
sessionStorage.removeItem("oauth_code_verifier");
sessionStorage.removeItem("oauth_server_url");
router.push(`/${params.locale}`);
} else {
setError("token_exchange_failed");
}
})
.catch(() => {
setError("token_exchange_failed");
});
}, []); // eslint-disable-line react-hooks/exhaustive-deps
if (error) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-muted/20">
<div className="w-full max-w-sm mx-auto px-4 text-center">
<div className="inline-flex items-center justify-center w-20 h-20 rounded-2xl bg-red-500/10 mb-6">
<AlertCircle className="w-10 h-10 text-red-500" />
</div>
<h1 className="text-xl font-medium text-foreground mb-2">
{t("oauth_error.title")}
</h1>
<p className="text-muted-foreground text-sm mb-6">
{t(`oauth_error.${error}`)}
</p>
<Button
variant="outline"
onClick={() => router.push(`/${params.locale}/login`)}
>
{t("oauth_error.back_to_login")}
</Button>
</div>
</div>
);
}
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-muted/20">
<div className="w-full max-w-sm mx-auto px-4 text-center" role="status">
<Loader2 className="w-8 h-8 animate-spin text-primary mx-auto mb-4" />
<p className="text-muted-foreground text-sm">{t("oauth_completing")}</p>
</div>
</div>
);
}
export default function OAuthCallbackPage() {
return (
<Suspense
fallback={
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-muted/20">
<div className="w-full max-w-sm mx-auto px-4 text-center" role="status">
<Loader2 className="w-8 h-8 animate-spin text-primary mx-auto mb-4" />
</div>
</div>
}
>
<OAuthCallbackInner />
</Suspense>
);
}
+86 -2
View File
@@ -2,19 +2,24 @@
import { useState, useEffect, useRef } from "react";
import { useRouter } from "@/i18n/navigation";
import { useParams } from "next/navigation";
import { useTranslations } from "next-intl";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { useAuthStore } from "@/stores/auth-store";
import { useConfig } from "@/hooks/use-config";
import { cn } from "@/lib/utils";
import { Mail, AlertCircle, Loader2, X, ShieldCheck, Info, Eye, EyeOff } from "lucide-react";
import { Mail, AlertCircle, Loader2, X, ShieldCheck, Info, Eye, EyeOff, LogIn } from "lucide-react";
import { discoverOAuth, type OAuthMetadata } from "@/lib/oauth/discovery";
import { generateCodeVerifier, generateCodeChallenge, generateState } from "@/lib/oauth/pkce";
import { OAUTH_SCOPES } from "@/lib/oauth/tokens";
export default function LoginPage() {
const router = useRouter();
const t = useTranslations("login");
const params = useParams();
const { login, isLoading, error, clearError, isAuthenticated } = useAuthStore();
const { appName, jmapServerUrl: serverUrl, isLoading: configLoading, error: configError } = useConfig();
const { appName, jmapServerUrl: serverUrl, oauthEnabled, oauthClientId, oauthIssuerUrl, isLoading: configLoading, error: configError } = useConfig();
const [formData, setFormData] = useState({
username: "",
@@ -30,6 +35,10 @@ export default function LoginPage() {
const [showSuggestions, setShowSuggestions] = useState(false);
const [filteredSuggestions, setFilteredSuggestions] = useState<string[]>([]);
const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1);
const [oauthMetadata, setOauthMetadata] = useState<OAuthMetadata | null>(null);
const [oauthDiscoveryDone, setOauthDiscoveryDone] = useState(false);
const [oauthLoading, setOauthLoading] = useState(false);
const suggestionsRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const justSelectedSuggestion = useRef(false);
@@ -124,6 +133,19 @@ export default function LoginPage() {
}
}, [showTotpField]);
useEffect(() => {
if (!oauthEnabled || !serverUrl) return;
discoverOAuth(oauthIssuerUrl || serverUrl)
.then((metadata) => {
setOauthMetadata(metadata);
setOauthDiscoveryDone(true);
})
.catch(() => {
setOauthMetadata(null);
setOauthDiscoveryDone(true);
});
}, [oauthEnabled, serverUrl, oauthIssuerUrl]);
if (configLoading) {
return (
<div className="min-h-screen flex items-center justify-center bg-gradient-to-br from-background via-background to-muted/20">
@@ -236,6 +258,31 @@ export default function LoginPage() {
}
};
const handleOAuthLogin = async () => {
if (!oauthMetadata || !oauthClientId) return;
setOauthLoading(true);
const verifier = generateCodeVerifier();
const challenge = await generateCodeChallenge(verifier);
const state = generateState();
const redirectUri = `${window.location.origin}/${params.locale}/auth/callback`;
sessionStorage.setItem("oauth_code_verifier", verifier);
sessionStorage.setItem("oauth_state", state);
sessionStorage.setItem("oauth_server_url", serverUrl!);
const authUrl = new URL(oauthMetadata.authorization_endpoint);
authUrl.searchParams.set("response_type", "code");
authUrl.searchParams.set("client_id", oauthClientId);
authUrl.searchParams.set("redirect_uri", redirectUri);
authUrl.searchParams.set("scope", OAUTH_SCOPES);
authUrl.searchParams.set("state", state);
authUrl.searchParams.set("code_challenge", challenge);
authUrl.searchParams.set("code_challenge_method", "S256");
window.location.href = authUrl.toString();
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -453,6 +500,43 @@ export default function LoginPage() {
t("sign_in")
)}
</Button>
{oauthMetadata && (
<>
<div className="relative my-6">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t border-border" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-background px-2 text-muted-foreground">{t("or")}</span>
</div>
</div>
<Button
type="button"
variant="outline"
className="w-full h-12 font-medium text-base"
onClick={handleOAuthLogin}
disabled={oauthLoading || isLoading}
>
{oauthLoading ? (
<Loader2 className="w-4 h-4 animate-spin mr-2" />
) : (
<LogIn className="w-4 h-4 mr-2" />
)}
{t("sign_in_sso")}
</Button>
</>
)}
{oauthEnabled && oauthDiscoveryDone && !oauthMetadata && (
<div className="mt-4 p-3 bg-amber-500/10 border border-amber-500/20 rounded-lg flex items-start gap-2">
<AlertCircle className="w-4 h-4 text-amber-700 dark:text-amber-400 flex-shrink-0 mt-0.5" />
<p className="text-sm text-amber-700 dark:text-amber-400">
{t("error.oauth_discovery_failed")}
</p>
</div>
)}
</form>
</div>
</div>
+193
View File
@@ -0,0 +1,193 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { logger } from '@/lib/logger';
import { discoverOAuth } from '@/lib/oauth/discovery';
import { REFRESH_TOKEN_COOKIE } from '@/lib/oauth/tokens';
const CLIENT_SECRET = process.env.OAUTH_CLIENT_SECRET || '';
const COOKIE_OPTIONS = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/',
maxAge: 30 * 24 * 60 * 60,
};
function getRequiredConfig() {
const clientId = process.env.OAUTH_CLIENT_ID;
const serverUrl = process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL;
const issuerUrl = process.env.OAUTH_ISSUER_URL;
if (!clientId || !serverUrl) {
throw new Error(`OAuth misconfigured: ${[!clientId && 'OAUTH_CLIENT_ID', !serverUrl && 'JMAP_SERVER_URL'].filter(Boolean).join(', ')} not set`);
}
const discoveryUrl = issuerUrl?.trim() || serverUrl;
if (issuerUrl !== undefined && !issuerUrl.trim()) {
logger.warn('OAUTH_ISSUER_URL is set but empty, falling back to JMAP_SERVER_URL for discovery');
}
return { clientId, serverUrl, discoveryUrl };
}
async function getTokenEndpoint(): Promise<string> {
const { discoveryUrl } = getRequiredConfig();
const metadata = await discoverOAuth(discoveryUrl);
if (!metadata?.token_endpoint) {
throw new Error('OAuth token endpoint not found');
}
return metadata.token_endpoint;
}
async function getRevocationEndpoint(): Promise<string | null> {
const { discoveryUrl } = getRequiredConfig();
const metadata = await discoverOAuth(discoveryUrl);
return metadata?.revocation_endpoint || null;
}
function buildOAuthParams(base: Record<string, string>): URLSearchParams {
const { clientId } = getRequiredConfig();
const params = new URLSearchParams({ ...base, client_id: clientId });
if (CLIENT_SECRET) {
params.set('client_secret', CLIENT_SECRET);
}
return params;
}
export async function POST(request: NextRequest) {
try {
const { code, code_verifier, redirect_uri } = await request.json();
if (!code || !code_verifier || !redirect_uri) {
return NextResponse.json({ error: 'Missing required parameters' }, { status: 400 });
}
const tokenEndpoint = await getTokenEndpoint();
const params = buildOAuthParams({
grant_type: 'authorization_code',
code,
redirect_uri,
code_verifier,
});
const tokenResponse = await fetch(tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
});
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text();
logger.error('Token exchange failed', { status: tokenResponse.status, error: errorText });
return NextResponse.json({ error: 'Token exchange failed' }, { status: 401 });
}
const tokens = await tokenResponse.json();
if (!tokens.access_token) {
logger.error('Token response missing access_token', { response: JSON.stringify(tokens).substring(0, 500) });
return NextResponse.json({ error: 'Invalid token response' }, { status: 502 });
}
const response = NextResponse.json({
access_token: tokens.access_token,
expires_in: tokens.expires_in || 3600,
});
if (tokens.refresh_token) {
const cookieStore = await cookies();
cookieStore.set(REFRESH_TOKEN_COOKIE, tokens.refresh_token, COOKIE_OPTIONS);
}
return response;
} catch (error) {
logger.error('Token exchange error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function PUT() {
try {
const cookieStore = await cookies();
const refreshToken = cookieStore.get(REFRESH_TOKEN_COOKIE)?.value;
if (!refreshToken) {
return NextResponse.json({ error: 'No refresh token' }, { status: 401 });
}
const tokenEndpoint = await getTokenEndpoint();
const params = buildOAuthParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
});
const tokenResponse = await fetch(tokenEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
});
if (!tokenResponse.ok) {
const errorText = await tokenResponse.text();
logger.error('Token refresh failed', { status: tokenResponse.status, error: errorText });
cookieStore.delete(REFRESH_TOKEN_COOKIE);
return NextResponse.json({ error: 'Refresh failed' }, { status: 401 });
}
const tokens = await tokenResponse.json();
if (!tokens.access_token) {
logger.error('Refresh response missing access_token', { response: JSON.stringify(tokens).substring(0, 500) });
return NextResponse.json({ error: 'Invalid token response' }, { status: 502 });
}
if (tokens.refresh_token) {
cookieStore.set(REFRESH_TOKEN_COOKIE, tokens.refresh_token, COOKIE_OPTIONS);
}
return NextResponse.json({
access_token: tokens.access_token,
expires_in: tokens.expires_in || 3600,
});
} catch (error) {
logger.error('Token refresh error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
export async function DELETE() {
try {
const cookieStore = await cookies();
const refreshToken = cookieStore.get(REFRESH_TOKEN_COOKIE)?.value;
if (refreshToken) {
const revocationEndpoint = await getRevocationEndpoint();
if (revocationEndpoint) {
const params = buildOAuthParams({
token: refreshToken,
token_type_hint: 'refresh_token',
});
try {
const revocationResponse = await fetch(revocationEndpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: params.toString(),
});
if (!revocationResponse.ok) {
logger.warn('Token revocation returned error', { status: revocationResponse.status });
}
} catch (err) {
logger.error('Token revocation network error', { error: err instanceof Error ? err.message : 'Unknown error' });
}
}
cookieStore.delete(REFRESH_TOKEN_COOKIE);
}
return NextResponse.json({ ok: true });
} catch (error) {
logger.error('Token revocation error', { error: error instanceof Error ? error.message : 'Unknown error' });
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
+3
View File
@@ -18,5 +18,8 @@ export async function GET() {
return NextResponse.json({
appName: process.env.APP_NAME || process.env.NEXT_PUBLIC_APP_NAME || 'Webmail',
jmapServerUrl: process.env.JMAP_SERVER_URL || process.env.NEXT_PUBLIC_JMAP_SERVER_URL || '',
oauthEnabled: process.env.OAUTH_ENABLED === 'true',
oauthClientId: process.env.OAUTH_CLIENT_ID || '',
oauthIssuerUrl: process.env.OAUTH_ISSUER_URL || '',
});
}
+19 -5
View File
@@ -2,18 +2,23 @@
import { useState, useEffect } from 'react';
interface AppConfig {
interface ConfigData {
appName: string;
jmapServerUrl: string;
oauthEnabled: boolean;
oauthClientId: string;
oauthIssuerUrl: string;
}
interface AppConfig extends ConfigData {
isLoading: boolean;
error: string | null;
}
// Cache the config to avoid multiple fetches
let configCache: { appName: string; jmapServerUrl: string } | null = null;
let configPromise: Promise<{ appName: string; jmapServerUrl: string }> | null = null;
let configCache: ConfigData | null = null;
let configPromise: Promise<ConfigData> | null = null;
async function fetchConfig(): Promise<{ appName: string; jmapServerUrl: string }> {
async function fetchConfig(): Promise<ConfigData> {
// Return cached config if available
if (configCache) {
return configCache;
@@ -55,6 +60,9 @@ export function useConfig(): AppConfig {
const [config, setConfig] = useState<AppConfig>({
appName: configCache?.appName || 'Webmail',
jmapServerUrl: configCache?.jmapServerUrl || '',
oauthEnabled: configCache?.oauthEnabled || false,
oauthClientId: configCache?.oauthClientId || '',
oauthIssuerUrl: configCache?.oauthIssuerUrl || '',
isLoading: !configCache,
error: null,
});
@@ -65,6 +73,9 @@ export function useConfig(): AppConfig {
setConfig({
appName: configCache.appName,
jmapServerUrl: configCache.jmapServerUrl,
oauthEnabled: configCache.oauthEnabled,
oauthClientId: configCache.oauthClientId,
oauthIssuerUrl: configCache.oauthIssuerUrl,
isLoading: false,
error: null,
});
@@ -76,6 +87,9 @@ export function useConfig(): AppConfig {
setConfig({
appName: data.appName,
jmapServerUrl: data.jmapServerUrl,
oauthEnabled: data.oauthEnabled,
oauthClientId: data.oauthClientId,
oauthIssuerUrl: data.oauthIssuerUrl,
isLoading: false,
error: null,
});
+4 -3
View File
@@ -161,14 +161,15 @@ export class JMAPClient {
const sessionUrl = `${this.serverUrl}/.well-known/jmap`;
try {
const sessionResponse = await fetch(sessionUrl, {
const sessionResponse = await this.authenticatedFetch(sessionUrl, {
method: 'GET',
headers: { 'Authorization': this.authHeader },
});
if (!sessionResponse.ok) {
if (sessionResponse.status === 401) {
throw new Error('Invalid username or password');
throw new Error(this.authMode === 'bearer'
? 'Authentication failed - token may be expired'
: 'Invalid username or password');
}
throw new Error(`Failed to get session: ${sessionResponse.status}`);
}
+53
View File
@@ -0,0 +1,53 @@
export interface OAuthMetadata {
issuer: string;
authorization_endpoint: string;
token_endpoint: string;
revocation_endpoint?: string;
end_session_endpoint?: string;
}
const CACHE_TTL_MS = 10 * 60 * 1000;
const metadataCache = new Map<string, { metadata: OAuthMetadata; expiresAt: number }>();
export async function discoverOAuth(serverUrl: string): Promise<OAuthMetadata | null> {
const cached = metadataCache.get(serverUrl);
if (cached && cached.expiresAt > Date.now()) return cached.metadata;
if (cached) metadataCache.delete(serverUrl);
const urls = [
`${serverUrl}/.well-known/oauth-authorization-server`,
`${serverUrl}/.well-known/openid-configuration`,
];
const errors: string[] = [];
for (const url of urls) {
try {
const response = await fetch(url);
if (!response.ok) {
errors.push(`${url} returned HTTP ${response.status}`);
continue;
}
const data = await response.json();
if (data.authorization_endpoint && data.token_endpoint) {
const metadata: OAuthMetadata = {
issuer: data.issuer,
authorization_endpoint: data.authorization_endpoint,
token_endpoint: data.token_endpoint,
revocation_endpoint: data.revocation_endpoint,
end_session_endpoint: data.end_session_endpoint,
};
metadataCache.set(serverUrl, { metadata, expiresAt: Date.now() + CACHE_TTL_MS });
return metadata;
}
errors.push(`${url} response missing required endpoints`);
} catch (err) {
errors.push(`${url}: ${err instanceof Error ? err.message : String(err)}`);
continue;
}
}
console.error(`[OAuth] Discovery failed for ${serverUrl}: ${errors.join('; ')}`);
return null;
}
+27
View File
@@ -0,0 +1,27 @@
function base64urlEncode(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
export function generateCodeVerifier(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return base64urlEncode(array.buffer);
}
export async function generateCodeChallenge(verifier: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const digest = await crypto.subtle.digest('SHA-256', data);
return base64urlEncode(digest);
}
export function generateState(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return base64urlEncode(array.buffer);
}
+2
View File
@@ -0,0 +1,2 @@
export const OAUTH_SCOPES = 'openid email profile';
export const REFRESH_TOKEN_COOKIE = 'jmap_rt';
+13 -1
View File
@@ -14,6 +14,7 @@
"cors_blocked": "Der Server ist erreichbar, blockiert aber Cross-Origin-Anfragen. Überprüfen Sie die CORS-Einstellungen Ihres JMAP-Servers und erlauben Sie diese Domain.",
"generic": "Ein Fehler ist aufgetreten. Bitte versuchen Sie es erneut.",
"totp_invalid": "Ungültiger Authentifizierungscode. Überprüfen Sie Ihre Authenticator-App.",
"oauth_discovery_failed": "SSO ist aktiviert, aber der Identitätsanbieter ist nicht erreichbar. Überprüfen Sie Ihre OAuth-Konfiguration.",
"server_error": "Der Server ist vorübergehend nicht erreichbar. Bitte versuchen Sie es später erneut."
},
"config_error": {
@@ -31,7 +32,18 @@
"totp_hint": "Aktivieren Sie dies, wenn Ihr Konto einen Zwei-Faktor-Code erfordert",
"totp_checkbox": "Zwei-Faktor-Authentifizierungscode verwenden",
"session_expired": "Ihre Sitzung ist abgelaufen. Bitte melden Sie sich erneut an.",
"dismiss": "Schließen"
"dismiss": "Schließen",
"or": "oder",
"sign_in_sso": "Mit SSO anmelden",
"oauth_completing": "Anmeldung wird abgeschlossen...",
"oauth_error": {
"title": "Authentifizierung fehlgeschlagen",
"invalid_state": "Sicherheitsvalidierung fehlgeschlagen. Bitte erneut anmelden.",
"missing_params": "Fehlende Autorisierungsdaten. Bitte erneut anmelden.",
"token_exchange_failed": "Authentifizierung konnte nicht abgeschlossen werden. Bitte erneut versuchen.",
"access_denied": "Zugriff verweigert. Bitte kontaktieren Sie Ihren Administrator.",
"back_to_login": "Zurück zur Anmeldung"
}
},
"sidebar": {
"close": "Schließen",
+14 -2
View File
@@ -14,7 +14,8 @@
"cors_blocked": "The server is reachable but is blocking cross-origin requests. Check your JMAP server's CORS settings and allow this domain.",
"server_error": "The server is temporarily unavailable. Please try again later.",
"generic": "An unexpected error occurred. If this persists, contact your administrator.",
"totp_invalid": "Invalid authentication code. Please check your authenticator app and try again."
"totp_invalid": "Invalid authentication code. Please check your authenticator app and try again.",
"oauth_discovery_failed": "SSO is enabled but the identity provider could not be reached. Check your OAuth configuration."
},
"show_password": "Show password",
"hide_password": "Hide password",
@@ -31,7 +32,18 @@
"totp_hide": "Hide two-factor authentication",
"totp_checkbox": "Use two-factor authentication code",
"session_expired": "Your session has expired. Please sign in again.",
"dismiss": "Dismiss"
"dismiss": "Dismiss",
"or": "or",
"sign_in_sso": "Sign in with SSO",
"oauth_completing": "Completing sign in...",
"oauth_error": {
"title": "Authentication Failed",
"invalid_state": "Security validation failed. Please try signing in again.",
"missing_params": "Missing authorization data. Please try signing in again.",
"token_exchange_failed": "Failed to complete authentication. Please try again.",
"access_denied": "Access was denied. Please contact your administrator.",
"back_to_login": "Back to login"
}
},
"sidebar": {
"close": "Close",
+13 -1
View File
@@ -14,6 +14,7 @@
"cors_blocked": "El servidor es accesible pero está bloqueando las solicitudes de origen cruzado. Verifique la configuración CORS de su servidor JMAP y permita este dominio.",
"generic": "Ocurrió un error. Por favor, inténtelo de nuevo.",
"totp_invalid": "Código de autenticación inválido. Verifica tu aplicación de autenticación.",
"oauth_discovery_failed": "SSO está habilitado pero no se pudo contactar al proveedor de identidad. Verifica tu configuración OAuth.",
"server_error": "El servidor no está disponible temporalmente. Inténtalo más tarde."
},
"config_error": {
@@ -31,7 +32,18 @@
"totp_hint": "Activa si tu cuenta requiere un código de autenticación en dos pasos",
"totp_checkbox": "Usar código de autenticación en dos pasos",
"session_expired": "Tu sesión ha expirado. Inicia sesión de nuevo.",
"dismiss": "Cerrar"
"dismiss": "Cerrar",
"or": "o",
"sign_in_sso": "Iniciar sesión con SSO",
"oauth_completing": "Completando inicio de sesión...",
"oauth_error": {
"title": "Error de autenticación",
"invalid_state": "La validación de seguridad falló. Intenta iniciar sesión de nuevo.",
"missing_params": "Faltan datos de autorización. Intenta iniciar sesión de nuevo.",
"token_exchange_failed": "No se pudo completar la autenticación. Inténtalo de nuevo.",
"access_denied": "Acceso denegado. Contacta a tu administrador.",
"back_to_login": "Volver al inicio de sesión"
}
},
"sidebar": {
"close": "Cerrar",
+13 -1
View File
@@ -14,6 +14,7 @@
"cors_blocked": "Le serveur est joignable mais bloque les requêtes cross-origin. Vérifiez la configuration CORS de votre serveur JMAP et autorisez ce domaine.",
"generic": "Une erreur s'est produite. Veuillez réessayer.",
"totp_invalid": "Code d'authentification invalide. Vérifiez votre application d'authentification.",
"oauth_discovery_failed": "Le SSO est activé mais le fournisseur d'identité est injoignable. Vérifiez votre configuration OAuth.",
"server_error": "Le serveur est temporairement indisponible. Veuillez réessayer plus tard."
},
"config_error": {
@@ -31,7 +32,18 @@
"dismiss": "Fermer",
"show_password": "Afficher le mot de passe",
"hide_password": "Masquer le mot de passe",
"totp_hint": "Activez si votre compte nécessite un code d'authentification à deux facteurs"
"totp_hint": "Activez si votre compte nécessite un code d'authentification à deux facteurs",
"or": "ou",
"sign_in_sso": "Se connecter avec SSO",
"oauth_completing": "Connexion en cours...",
"oauth_error": {
"title": "Échec de l'authentification",
"invalid_state": "La validation de sécurité a échoué. Veuillez réessayer.",
"missing_params": "Données d'autorisation manquantes. Veuillez réessayer.",
"token_exchange_failed": "Impossible de finaliser l'authentification. Veuillez réessayer.",
"access_denied": "L'accès a été refusé. Veuillez contacter votre administrateur.",
"back_to_login": "Retour à la connexion"
}
},
"sidebar": {
"close": "Fermer",
+13 -1
View File
@@ -14,6 +14,7 @@
"cors_blocked": "Il server è raggiungibile ma sta bloccando le richieste cross-origin. Controlla le impostazioni CORS del tuo server JMAP e consenti questo dominio.",
"generic": "Si è verificato un errore. Riprova.",
"totp_invalid": "Codice di autenticazione non valido. Controlla la tua app di autenticazione.",
"oauth_discovery_failed": "SSO è abilitato ma il provider di identità non è raggiungibile. Controlla la configurazione OAuth.",
"server_error": "Il server non è temporaneamente disponibile. Riprova più tardi."
},
"config_error": {
@@ -31,7 +32,18 @@
"totp_hint": "Attiva se il tuo account richiede un codice di autenticazione a due fattori",
"totp_checkbox": "Usa codice di autenticazione a due fattori",
"session_expired": "La sessione è scaduta. Accedi di nuovo.",
"dismiss": "Chiudi"
"dismiss": "Chiudi",
"or": "o",
"sign_in_sso": "Accedi con SSO",
"oauth_completing": "Completamento dell'accesso...",
"oauth_error": {
"title": "Autenticazione non riuscita",
"invalid_state": "La convalida di sicurezza è fallita. Prova ad accedere di nuovo.",
"missing_params": "Dati di autorizzazione mancanti. Prova ad accedere di nuovo.",
"token_exchange_failed": "Impossibile completare l'autenticazione. Riprova.",
"access_denied": "Accesso negato. Contatta il tuo amministratore.",
"back_to_login": "Torna al login"
}
},
"sidebar": {
"close": "Chiudi",
+13 -1
View File
@@ -14,6 +14,7 @@
"cors_blocked": "サーバーには到達できますが、クロスオリジンリクエストがブロックされています。JMAPサーバーのCORS設定を確認し、このドメインを許可してください。",
"generic": "エラーが発生しました。もう一度お試しください。",
"totp_invalid": "認証コードが無効です。認証アプリを確認してください。",
"oauth_discovery_failed": "SSOは有効ですが、IDプロバイダーに接続できません。OAuth設定を確認してください。",
"server_error": "サーバーが一時的に利用できません。後でもう一度お試しください。"
},
"config_error": {
@@ -31,7 +32,18 @@
"dismiss": "閉じる",
"show_password": "パスワードを表示",
"hide_password": "パスワードを隠す",
"totp_hint": "アカウントに二要素認証コードが必要な場合に有効にしてください"
"totp_hint": "アカウントに二要素認証コードが必要な場合に有効にしてください",
"or": "または",
"sign_in_sso": "SSOでサインイン",
"oauth_completing": "サインイン処理中...",
"oauth_error": {
"title": "認証に失敗しました",
"invalid_state": "セキュリティ検証に失敗しました。再度サインインしてください。",
"missing_params": "認証データが不足しています。再度サインインしてください。",
"token_exchange_failed": "認証を完了できませんでした。再度お試しください。",
"access_denied": "アクセスが拒否されました。管理者にお問い合わせください。",
"back_to_login": "ログインに戻る"
}
},
"sidebar": {
"close": "閉じる",
+13 -1
View File
@@ -14,6 +14,7 @@
"cors_blocked": "De server is bereikbaar maar blokkeert cross-origin verzoeken. Controleer de CORS-instellingen van uw JMAP-server en sta dit domein toe.",
"generic": "Er is een fout opgetreden. Probeer het opnieuw.",
"totp_invalid": "Ongeldige authenticatiecode. Controleer uw authenticator-app.",
"oauth_discovery_failed": "SSO is ingeschakeld maar de identiteitsprovider is niet bereikbaar. Controleer uw OAuth-configuratie.",
"server_error": "De server is tijdelijk niet beschikbaar. Probeer het later opnieuw."
},
"config_error": {
@@ -31,7 +32,18 @@
"totp_hint": "Schakel in als uw account een tweefactorauthenticatiecode vereist",
"totp_checkbox": "Tweefactorauthenticatiecode gebruiken",
"session_expired": "Uw sessie is verlopen. Meld u opnieuw aan.",
"dismiss": "Sluiten"
"dismiss": "Sluiten",
"or": "of",
"sign_in_sso": "Inloggen met SSO",
"oauth_completing": "Aanmelding voltooien...",
"oauth_error": {
"title": "Authenticatie mislukt",
"invalid_state": "Beveiligingsvalidatie mislukt. Probeer opnieuw in te loggen.",
"missing_params": "Ontbrekende autorisatiegegevens. Probeer opnieuw in te loggen.",
"token_exchange_failed": "Authenticatie kon niet worden voltooid. Probeer het opnieuw.",
"access_denied": "Toegang geweigerd. Neem contact op met uw beheerder.",
"back_to_login": "Terug naar inloggen"
}
},
"sidebar": {
"close": "Sluiten",
+13 -1
View File
@@ -14,6 +14,7 @@
"cors_blocked": "O servidor está acessível mas está bloqueando requisições de origem cruzada. Verifique as configurações de CORS do seu servidor JMAP e permita este domínio.",
"generic": "Ocorreu um erro. Por favor, tente novamente.",
"totp_invalid": "Código de autenticação inválido. Verifique seu aplicativo de autenticação.",
"oauth_discovery_failed": "SSO está ativado mas o provedor de identidade não pôde ser contactado. Verifique sua configuração OAuth.",
"server_error": "O servidor está temporariamente indisponível. Tente novamente mais tarde."
},
"config_error": {
@@ -31,7 +32,18 @@
"totp_hint": "Ative se sua conta requer um código de autenticação de dois fatores",
"totp_checkbox": "Usar código de autenticação de dois fatores",
"session_expired": "Sua sessão expirou. Faça login novamente.",
"dismiss": "Fechar"
"dismiss": "Fechar",
"or": "ou",
"sign_in_sso": "Entrar com SSO",
"oauth_completing": "Concluindo login...",
"oauth_error": {
"title": "Falha na autenticação",
"invalid_state": "A validação de segurança falhou. Tente entrar novamente.",
"missing_params": "Dados de autorização ausentes. Tente entrar novamente.",
"token_exchange_failed": "Não foi possível concluir a autenticação. Tente novamente.",
"access_denied": "Acesso negado. Entre em contato com seu administrador.",
"back_to_login": "Voltar ao login"
}
},
"sidebar": {
"close": "Fechar",
+232 -72
View File
@@ -19,13 +19,101 @@ interface AuthState {
client: JMAPClient | null;
identities: Identity[];
primaryIdentity: Identity | null;
authMode: 'basic' | 'oauth';
accessToken: string | null;
tokenExpiresAt: number | null;
login: (serverUrl: string, username: string, password: string, totp?: string) => Promise<boolean>;
loginWithOAuth: (serverUrl: string, code: string, codeVerifier: string, redirectUri: string) => Promise<boolean>;
refreshAccessToken: () => Promise<string | null>;
logout: () => void;
checkAuth: () => Promise<void>;
clearError: () => void;
}
const ERROR_PATTERNS: Array<{ key: string; matches: string[] }> = [
{ key: 'cors_blocked', matches: ['CORS_ERROR'] },
{ key: 'invalid_credentials', matches: ['Invalid username or password', '401', 'Unauthorized'] },
{ key: 'connection_failed', matches: ['network', 'Failed to fetch', 'NetworkError', 'ECONNREFUSED'] },
{ key: 'server_error', matches: ['500', '502', '503', '504', 'Internal Server Error', 'Service Unavailable'] },
];
function classifyLoginError(error: unknown): string {
if (!(error instanceof Error)) return 'generic';
const msg = error.message;
for (const { key, matches } of ERROR_PATTERNS) {
if (matches.some((pattern) => msg.includes(pattern))) return key;
}
return 'generic';
}
function loadIdentities(rawIdentities: Identity[], username: string): { identities: Identity[]; primaryIdentity: Identity | null } {
const identities = [...rawIdentities].sort((a, b) => {
const aMatch = a.email === username ? -1 : 0;
const bMatch = b.email === username ? -1 : 0;
return aMatch - bMatch;
});
const primaryIdentity = identities[0] ?? null;
useIdentityStore.getState().setIdentities(identities);
return { identities, primaryIdentity };
}
function markSessionExpired(): void {
try { sessionStorage.setItem('session_expired', 'true'); } catch { /* noop */ }
}
function initializeFeatureStores(client: JMAPClient): void {
if (client.supportsContacts()) {
const contactStore = useContactStore.getState();
contactStore.setSupportsSync(true);
contactStore.fetchAddressBooks(client).catch((err) => debug.error('Failed to fetch address books:', err));
contactStore.fetchContacts(client).catch((err) => debug.error('Failed to fetch contacts:', err));
} else {
useContactStore.getState().setSupportsSync(false);
}
const vacationStore = useVacationStore.getState();
if (client.supportsVacationResponse()) {
vacationStore.setSupported(true);
vacationStore.fetchVacationResponse(client).catch((err) => debug.error('Failed to fetch vacation response:', err));
} else {
vacationStore.setSupported(false);
}
if (client.supportsCalendars()) {
const calendarStore = useCalendarStore.getState();
calendarStore.setSupported(true);
calendarStore.fetchCalendars(client).catch((err) => debug.error('Failed to fetch calendars:', err));
}
if (client.supportsSieve()) {
const filterStore = useFilterStore.getState();
filterStore.setSupported(true);
filterStore.fetchFilters(client).catch((err) => debug.error('Failed to fetch filters:', err));
}
}
let refreshTimer: ReturnType<typeof setTimeout> | null = null;
let refreshPromise: Promise<string | null> | null = null;
function scheduleRefresh(expiresIn: number, refreshFn: () => Promise<string | null>): void {
if (refreshTimer) clearTimeout(refreshTimer);
const refreshAt = Math.max((expiresIn - 60) * 1000, 10_000);
refreshTimer = setTimeout(() => {
refreshFn().catch((err) => {
debug.error('Scheduled token refresh failed:', err);
});
}, refreshAt);
}
function clearRefreshTimer(): void {
if (refreshTimer) {
clearTimeout(refreshTimer);
refreshTimer = null;
}
refreshPromise = null;
}
export const useAuthStore = create<AuthState>()(
persist(
(set, get) => ({
@@ -37,6 +125,9 @@ export const useAuthStore = create<AuthState>()(
client: null,
identities: [],
primaryIdentity: null,
authMode: 'basic',
accessToken: null,
tokenExpiresAt: null,
login: async (serverUrl, username, password, totp) => {
const effectivePassword = totp ? `${password}$${totp}` : password;
@@ -46,46 +137,9 @@ export const useAuthStore = create<AuthState>()(
const client = new JMAPClient(serverUrl, username, effectivePassword);
await client.connect();
const rawIdentities = await client.getIdentities();
const identities = [...rawIdentities].sort((a, b) => {
const aMatch = a.email === username ? -1 : 0;
const bMatch = b.email === username ? -1 : 0;
return aMatch - bMatch;
});
const primaryIdentity = identities.length > 0 ? identities[0] : null;
useIdentityStore.getState().setIdentities(identities);
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), username);
initializeFeatureStores(client);
// Fetch contacts if server supports JMAP Contacts
if (client.supportsContacts()) {
const contactStore = useContactStore.getState();
contactStore.setSupportsSync(true);
contactStore.fetchAddressBooks(client).catch((err) => console.error('Failed to fetch address books:', err));
contactStore.fetchContacts(client).catch((err) => console.error('Failed to fetch contacts:', err));
} else {
useContactStore.getState().setSupportsSync(false);
}
const vacationStore = useVacationStore.getState();
if (client.supportsVacationResponse()) {
vacationStore.setSupported(true);
vacationStore.fetchVacationResponse(client).catch((err) => console.error('Failed to fetch vacation response:', err));
} else {
vacationStore.setSupported(false);
}
if (client.supportsCalendars()) {
const calendarStore = useCalendarStore.getState();
calendarStore.setSupported(true);
calendarStore.fetchCalendars(client).catch((err) => console.error('Failed to fetch calendars:', err));
}
if (client.supportsSieve()) {
const filterStore = useFilterStore.getState();
filterStore.setSupported(true);
filterStore.fetchFilters(client).catch((err) => debug.error('Failed to fetch filters:', err));
}
// Success - save state (but NOT the password)
set({
isAuthenticated: true,
isLoading: false,
@@ -94,39 +148,18 @@ export const useAuthStore = create<AuthState>()(
client,
identities,
primaryIdentity,
authMode: 'basic',
accessToken: null,
tokenExpiresAt: null,
error: null,
});
return true;
} catch (error) {
debug.error('Login error:', error);
let errorKey = 'generic';
if (error instanceof Error) {
if (error.message === 'CORS_ERROR') {
errorKey = 'cors_blocked';
} else if (error.message.includes('Invalid username or password') ||
error.message.includes('401') ||
error.message.includes('Unauthorized')) {
errorKey = 'invalid_credentials';
} else if (error.message.includes('network') ||
error.message.includes('Failed to fetch') ||
error.message.includes('NetworkError') ||
error.message.includes('ECONNREFUSED')) {
errorKey = 'connection_failed';
} else if (error.message.includes('500') ||
error.message.includes('502') ||
error.message.includes('503') ||
error.message.includes('504') ||
error.message.includes('Internal Server Error') ||
error.message.includes('Service Unavailable')) {
errorKey = 'server_error';
}
}
set({
isLoading: false,
error: errorKey,
error: classifyLoginError(error),
isAuthenticated: false,
client: null,
});
@@ -134,13 +167,108 @@ export const useAuthStore = create<AuthState>()(
}
},
loginWithOAuth: async (serverUrl, code, codeVerifier, redirectUri) => {
set({ isLoading: true, error: null });
try {
const tokenRes = await fetch('/api/auth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ code, code_verifier: codeVerifier, redirect_uri: redirectUri }),
});
if (!tokenRes.ok) {
throw new Error('token_exchange_failed');
}
const { access_token, expires_in } = await tokenRes.json();
const refreshFn = get().refreshAccessToken;
const client = JMAPClient.withBearer(serverUrl, access_token, '', () => refreshFn());
await client.connect();
const username = client.getUsername();
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), username);
initializeFeatureStores(client);
set({
isAuthenticated: true,
isLoading: false,
serverUrl,
username,
client,
identities,
primaryIdentity,
authMode: 'oauth',
accessToken: access_token,
tokenExpiresAt: Date.now() + expires_in * 1000,
error: null,
});
scheduleRefresh(expires_in, get().refreshAccessToken);
return true;
} catch (error) {
debug.error('OAuth login error:', error);
set({
isLoading: false,
error: error instanceof Error ? error.message : 'generic',
isAuthenticated: false,
client: null,
});
return false;
}
},
refreshAccessToken: async () => {
if (refreshPromise) return refreshPromise;
refreshPromise = (async () => {
try {
const res = await fetch('/api/auth/token', { method: 'PUT' });
if (!res.ok) {
markSessionExpired();
get().logout();
return null;
}
const { access_token, expires_in } = await res.json();
get().client?.updateAccessToken(access_token);
set({
accessToken: access_token,
tokenExpiresAt: Date.now() + expires_in * 1000,
});
scheduleRefresh(expires_in, get().refreshAccessToken);
return access_token;
} catch (error) {
debug.error('Token refresh failed:', error);
markSessionExpired();
get().logout();
return null;
} finally {
refreshPromise = null;
}
})();
return refreshPromise;
},
logout: () => {
const state = get();
if (state.client) {
state.client.disconnect();
if (state.authMode === 'oauth') {
fetch('/api/auth/token', { method: 'DELETE' }).catch((err) => {
debug.error('Token revocation failed:', err);
});
}
clearRefreshTimer();
state.client?.disconnect();
set({
isAuthenticated: false,
serverUrl: null,
@@ -148,6 +276,9 @@ export const useAuthStore = create<AuthState>()(
client: null,
identities: [],
primaryIdentity: null,
authMode: 'basic',
accessToken: null,
tokenExpiresAt: null,
error: null,
});
@@ -175,9 +306,35 @@ export const useAuthStore = create<AuthState>()(
const state = get();
if (state.isAuthenticated && !state.client) {
try {
sessionStorage.setItem('session_expired', 'true');
} catch { /* sessionStorage unavailable */ }
if (state.authMode === 'oauth' && state.serverUrl) {
set({ isLoading: true });
try {
const token = await get().refreshAccessToken();
if (token && state.serverUrl) {
const refreshFn = get().refreshAccessToken;
const client = JMAPClient.withBearer(state.serverUrl, token, state.username || '', () => refreshFn());
await client.connect();
const { identities, primaryIdentity } = loadIdentities(await client.getIdentities(), state.username || '');
initializeFeatureStores(client);
set({
isAuthenticated: true,
isLoading: false,
client,
identities,
primaryIdentity,
accessToken: token,
});
return;
}
} catch (error) {
debug.error('OAuth session restore failed:', error);
clearRefreshTimer();
}
}
markSessionExpired();
set({
isAuthenticated: false,
@@ -185,6 +342,9 @@ export const useAuthStore = create<AuthState>()(
client: null,
serverUrl: null,
username: null,
authMode: 'basic',
accessToken: null,
tokenExpiresAt: null,
});
}
@@ -196,11 +356,11 @@ export const useAuthStore = create<AuthState>()(
{
name: 'auth-storage',
partialize: (state) => ({
// Only persist non-sensitive data
serverUrl: state.serverUrl,
username: state.username,
// Don't persist isAuthenticated since we can't restore the session without a password
authMode: state.authMode,
isAuthenticated: state.authMode === 'oauth' ? state.isAuthenticated : undefined,
}),
}
)
);
);