diff --git a/README.md b/README.md index 8ad557ca..987e1b19 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/ROADMAP.md b/ROADMAP.md index b1fa6ea6..cac206fd 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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 diff --git a/app/[locale]/auth/callback/page.tsx b/app/[locale]/auth/callback/page.tsx new file mode 100644 index 00000000..ed74078d --- /dev/null +++ b/app/[locale]/auth/callback/page.tsx @@ -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(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 ( +
+
+
+ +
+

+ {t("oauth_error.title")} +

+

+ {t(`oauth_error.${error}`)} +

+ +
+
+ ); + } + + return ( +
+
+ +

{t("oauth_completing")}

+
+
+ ); +} + +export default function OAuthCallbackPage() { + return ( + +
+ +
+ + } + > + +
+ ); +} diff --git a/app/[locale]/login/page.tsx b/app/[locale]/login/page.tsx index a2168780..5939ae76 100644 --- a/app/[locale]/login/page.tsx +++ b/app/[locale]/login/page.tsx @@ -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([]); const [selectedSuggestionIndex, setSelectedSuggestionIndex] = useState(-1); + const [oauthMetadata, setOauthMetadata] = useState(null); + const [oauthDiscoveryDone, setOauthDiscoveryDone] = useState(false); + const [oauthLoading, setOauthLoading] = useState(false); + const suggestionsRef = useRef(null); const inputRef = useRef(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 (
@@ -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") )} + + {oauthMetadata && ( + <> +
+
+ +
+
+ {t("or")} +
+
+ + + + )} + + {oauthEnabled && oauthDiscoveryDone && !oauthMetadata && ( +
+ +

+ {t("error.oauth_discovery_failed")} +

+
+ )}
diff --git a/app/api/auth/token/route.ts b/app/api/auth/token/route.ts new file mode 100644 index 00000000..ed00f569 --- /dev/null +++ b/app/api/auth/token/route.ts @@ -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 { + 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 { + const { discoveryUrl } = getRequiredConfig(); + const metadata = await discoverOAuth(discoveryUrl); + return metadata?.revocation_endpoint || null; +} + +function buildOAuthParams(base: Record): 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 }); + } +} diff --git a/app/api/config/route.ts b/app/api/config/route.ts index 87ce3c1f..17f01d5b 100644 --- a/app/api/config/route.ts +++ b/app/api/config/route.ts @@ -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 || '', }); } diff --git a/hooks/use-config.ts b/hooks/use-config.ts index 8f75fdd2..d3990a25 100644 --- a/hooks/use-config.ts +++ b/hooks/use-config.ts @@ -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 | null = null; -async function fetchConfig(): Promise<{ appName: string; jmapServerUrl: string }> { +async function fetchConfig(): Promise { // Return cached config if available if (configCache) { return configCache; @@ -55,6 +60,9 @@ export function useConfig(): AppConfig { const [config, setConfig] = useState({ 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, }); diff --git a/lib/jmap/client.ts b/lib/jmap/client.ts index efb4cef0..ec87de71 100644 --- a/lib/jmap/client.ts +++ b/lib/jmap/client.ts @@ -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}`); } diff --git a/lib/oauth/discovery.ts b/lib/oauth/discovery.ts new file mode 100644 index 00000000..36f869b0 --- /dev/null +++ b/lib/oauth/discovery.ts @@ -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(); + +export async function discoverOAuth(serverUrl: string): Promise { + 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; +} diff --git a/lib/oauth/pkce.ts b/lib/oauth/pkce.ts new file mode 100644 index 00000000..c73eb883 --- /dev/null +++ b/lib/oauth/pkce.ts @@ -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 { + 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); +} diff --git a/lib/oauth/tokens.ts b/lib/oauth/tokens.ts new file mode 100644 index 00000000..f91df7c9 --- /dev/null +++ b/lib/oauth/tokens.ts @@ -0,0 +1,2 @@ +export const OAUTH_SCOPES = 'openid email profile'; +export const REFRESH_TOKEN_COOKIE = 'jmap_rt'; diff --git a/locales/de/common.json b/locales/de/common.json index 1df9f539..8163a2d8 100644 --- a/locales/de/common.json +++ b/locales/de/common.json @@ -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", diff --git a/locales/en/common.json b/locales/en/common.json index c43677a6..0e0dd2db 100644 --- a/locales/en/common.json +++ b/locales/en/common.json @@ -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", diff --git a/locales/es/common.json b/locales/es/common.json index c0ad0fc1..9c4b4e8f 100644 --- a/locales/es/common.json +++ b/locales/es/common.json @@ -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", diff --git a/locales/fr/common.json b/locales/fr/common.json index a719bbcf..f904d4b7 100644 --- a/locales/fr/common.json +++ b/locales/fr/common.json @@ -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", diff --git a/locales/it/common.json b/locales/it/common.json index a35b262b..9fa01b74 100644 --- a/locales/it/common.json +++ b/locales/it/common.json @@ -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", diff --git a/locales/ja/common.json b/locales/ja/common.json index b54be060..3935b05c 100644 --- a/locales/ja/common.json +++ b/locales/ja/common.json @@ -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": "閉じる", diff --git a/locales/nl/common.json b/locales/nl/common.json index 879bdcf6..6abdc2b3 100644 --- a/locales/nl/common.json +++ b/locales/nl/common.json @@ -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", diff --git a/locales/pt/common.json b/locales/pt/common.json index eda7b891..a60cbe98 100644 --- a/locales/pt/common.json +++ b/locales/pt/common.json @@ -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", diff --git a/stores/auth-store.ts b/stores/auth-store.ts index e04c7332..b1a35d00 100644 --- a/stores/auth-store.ts +++ b/stores/auth-store.ts @@ -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; + loginWithOAuth: (serverUrl: string, code: string, codeVerifier: string, redirectUri: string) => Promise; + refreshAccessToken: () => Promise; logout: () => void; checkAuth: () => Promise; 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 | null = null; +let refreshPromise: Promise | null = null; + +function scheduleRefresh(expiresIn: number, refreshFn: () => Promise): 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()( persist( (set, get) => ({ @@ -37,6 +125,9 @@ export const useAuthStore = create()( 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()( 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()( 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()( } }, + 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()( client: null, identities: [], primaryIdentity: null, + authMode: 'basic', + accessToken: null, + tokenExpiresAt: null, error: null, }); @@ -175,9 +306,35 @@ export const useAuthStore = create()( 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()( client: null, serverUrl: null, username: null, + authMode: 'basic', + accessToken: null, + tokenExpiresAt: null, }); } @@ -196,11 +356,11 @@ export const useAuthStore = create()( { 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, }), } ) -); \ No newline at end of file +);