From 7cb59eb627f8cbfc87a2510a97becdb96484459b Mon Sep 17 00:00:00 2001 From: Markus Mauch Date: Mon, 23 Mar 2026 10:55:57 +0100 Subject: [PATCH] Add sso-auth plugin: transparent SSO via reverse proxy header Authenticates users via a trusted reverse proxy that sets an HTTP header with the authenticated user's e-mail address (e.g. Authelia, Caddy, Traefik, Nginx auth_request). Unlike the existing proxy-auth plugin, no master IMAP user is required. Users enter their own IMAP password once; it is stored encrypted and reused for all future logins. Features: - Works with any standard IMAP server - Credentials stored encrypted per-user (APP_SALT + SSO email) - Session identity tracking via cookie to handle user switches - Loop-guard cookie prevents redirect loops on failed auto-login - Additional accounts (e.g. Gmail) continue to work normally - Configurable header name and redirect URL Co-Authored-By: Claude Sonnet 4.6 --- plugins/sso-auth/README.md | 125 +++++++++++++++ plugins/sso-auth/index.php | 309 +++++++++++++++++++++++++++++++++++++ 2 files changed, 434 insertions(+) create mode 100644 plugins/sso-auth/README.md create mode 100644 plugins/sso-auth/index.php diff --git a/plugins/sso-auth/README.md b/plugins/sso-auth/README.md new file mode 100644 index 000000000..2ed351a7c --- /dev/null +++ b/plugins/sso-auth/README.md @@ -0,0 +1,125 @@ +# SnappyMail SSO Auth + +Transparent single sign-on for SnappyMail via a trusted reverse proxy that sets an HTTP header with the authenticated user's e-mail address. + +Works with any forward-auth proxy: **Authelia**, **Caddy**, **Traefik**, **Nginx** (auth_request), or any other solution that sets a `Remote-Email`-style header after authentication. + +## How it differs from `proxy-auth` + +| | `proxy-auth` | `sso-auth` | +|---|---|---| +| IMAP credentials | Requires a master user account on the IMAP server | Uses each user's own credentials | +| IMAP server requirement | Must support master user / impersonation | Any standard IMAP server | +| First login | Fully automatic | User enters own password once | +| Subsequent logins | Fully automatic | Fully automatic | + +Use `sso-auth` when you cannot or do not want to configure a master user on your IMAP server. + +## Flow + +``` +Browser → Reverse Proxy → SnappyMail + ↓ + Sets Remote-Email header + ↓ + Plugin reads header + ↓ + Credentials stored? ──yes──→ Auto-login (no dialog) + ↓ no + Show login form (e-mail pre-filled) + ↓ + User enters IMAP password + ↓ + Credentials stored encrypted + ↓ + Future visits: fully automatic +``` + +## Setup + +### 1. Reverse proxy + +Configure your proxy to authenticate the user and set the `Remote-Email` header. Example for **Authelia** with Nginx Proxy Manager: + +```nginx +auth_request /internal/authelia/authz; +auth_request_set $redirection_url $upstream_http_location; +auth_request_set $email $upstream_http_remote_email; + +# Return JSON for XHR requests when session expires, redirect for page navigation +error_page 401 =200 @authelia_error; +location @authelia_error { + default_type application/json; + if ($request_method = POST) { + return 200 '{"Result":true}'; + } + return 302 $redirection_url; +} + +location / { + proxy_set_header Remote-Email $email; + # ... +} +``` + +> **Note on `{"Result":true}` for POST:** When a SnappyMail XHR request arrives with an +> expired proxy session, returning this response causes SnappyMail to call +> `location.reload()`, which triggers a full page reload. The page load is then +> intercepted by the proxy (401 → redirect to login). This avoids error dialogs in the UI. + +### 2. SnappyMail — application.ini + +If your proxy redirects to SnappyMail from a different (sub)domain, allow the +`Sec-Fetch-Site: same-site` header so SnappyMail accepts the final `/?sso&hash=` request: + +```ini +[security] +secfetch_allow = "site=same-site" +``` + +For logout, set a custom logout link pointing to your SSO provider: + +```ini +[labs] +custom_logout_link = "https://auth.example.com/logout?rd=https://mail.example.com" +``` + +### 3. Activate the plugin + +In the SnappyMail admin panel → Extensions → enable **SSO Auth**. + +Configure: + +| Setting | Description | Example | +|---------|-------------|---------| +| **Redirect URL** | Where to redirect when the SSO header is absent. Leave empty to show the normal login dialog. | `https://auth.example.com` | +| **Header name** | HTTP header set by the proxy with the user's e-mail address. | `Remote-Email` | + +## Credential storage + +Each user's IMAP password is stored encrypted in: + +``` +APP_PRIVATE_DATA/storage/_sso_/{sanitized-email}/primary.json +``` + +The password is encrypted with `APP_SALT + SSO-email` using SnappyMail's built-in +`SnappyMail\Crypt` class. No plaintext credentials are stored. + +## Additional accounts + +Users can add extra IMAP accounts (e.g. a Gmail account) via the standard SnappyMail +account switcher. The plugin does not interfere with additional account management. + +## Session tracking + +The plugin sets a `_sso_user` cookie (SHA-1 of the SSO e-mail) to track which identity +owns the current session. If the proxy reports a different user on the next page load +(e.g. after an overnight session where another family member logged in), the old session +is cleared and the new user is logged in automatically. + +## Loop protection + +A `_sso_pending` cookie (120 s TTL) is set immediately before the `/?sso&hash=` redirect. +If auto-login fails (e.g. wrong stored password), the cookie prevents an infinite redirect +loop and the login form is shown instead so the user can re-enter their password. diff --git a/plugins/sso-auth/index.php b/plugins/sso-auth/index.php new file mode 100644 index 000000000..7aad7ca91 --- /dev/null +++ b/plugins/sso-auth/index.php @@ -0,0 +1,309 @@ +addHook('filter.http-paths', 'onFilterHttpPaths'); + $this->addHook('login.credentials.step-2', 'onLoginCredentialsStep2'); + $this->addHook('login.success', 'onLoginSuccess'); + } + + protected function configMapping(): array + { + return [ + Property::NewInstance('redirect_url') + ->SetType(PluginPropertyType::STRING) + ->SetLabel('Redirect URL') + ->SetDescription( + 'Where to redirect when the SSO header is absent (e.g. your SSO login page). ' + . 'Leave empty to show the normal SnappyMail login dialog instead.' + ) + ->SetDefaultValue(''), + + Property::NewInstance('header_name') + ->SetType(PluginPropertyType::STRING) + ->SetLabel('Header name') + ->SetDescription( + 'HTTP header set by the reverse proxy containing the authenticated user\'s e-mail address. ' + . 'Default: Remote-Email' + ) + ->SetDefaultValue('Remote-Email'), + ]; + } + + // ───────────────────────────────────────────────────────────────────────── + // Hooks + // ───────────────────────────────────────────────────────────────────────── + + /** + * Hook: filter.http-paths + * + * Fires very early in every request, before routing. We intercept + * index-page requests that are not yet authenticated: + * + * • No SSO header → redirect to configured URL (or fall through to login dialog) + * • SSO header + credentials stored → create SSO hash, redirect to /?sso + * • SSO header + no credentials → fall through to login dialog + * + * @param array $aPaths URL path segments (passed by reference from Service::Handle) + */ + public function onFilterHttpPaths(array &$aPaths): void + { + // ── Skip non-index paths (sso, json, mailto, admin, …) ─────────────── + $sPath = !empty($aPaths[0]) ? strtolower($aPaths[0]) : ''; + if ($sPath && 'index' !== $sPath) { + return; + } + + // ── Skip admin panel requests ──────────────────────────────────────── + $sAdminKey = \RainLoop\Api::Config()->Get('admin_panel', 'key', '') ?: 'admin'; + $sQS = trim($_SERVER['QUERY_STRING'] ?? ''); + if (str_starts_with($sQS, $sAdminKey)) { + return; + } + + // ── Read SSO e-mail header ─────────────────────────────────────────── + $sSsoEmail = $this->getSsoEmail(); + + // ── Skip if the same SSO identity already owns this session ────────── + // We track the active SSO identity via a cookie (_sso_user = sha1 of + // SSO e-mail). If an account is active AND the cookie matches the + // current SSO e-mail the user is simply browsing — leave the session alone. + // If the cookie is missing or belongs to a different SSO identity we + // fall through and force a re-login (e.g. overnight session where the + // proxy now reports a different user). + if (\RainLoop\Api::Actions()->getAccountFromToken(false)) { + $sSsoUserHash = $_COOKIE[self::COOKIE_SSO_USER] ?? ''; + if ($sSsoEmail && $sSsoUserHash === sha1($sSsoEmail)) { + return; // Same SSO identity → keep session + } + // Different SSO identity: force re-login. + // Clear stale session cookie so SnappyMail accepts the new login. + \SnappyMail\Cookies::clear(\RainLoop\Utils::SESSION_TOKEN); + \SnappyMail\Cookies::clear(\RainLoop\Actions::AUTH_ADDITIONAL_TOKEN_KEY); + } + + if (!$sSsoEmail) { + // No header → redirect to configured SSO login page (if set) + $sUrl = trim($this->Config()->Get('plugin', 'redirect_url', '')); + if ($sUrl) { + \MailSo\Base\Http::Location($sUrl); + exit; + } + return; // No redirect URL configured: show normal login dialog + } + + // ── If the previous auto-login attempt failed, show the login form ─── + // (the _sso_pending cookie is set just before we redirect to /?sso) + if (!empty($_COOKIE[self::COOKIE_SSO_PENDING])) { + $this->clearPendingCookie(); + return; + } + + // ── Load stored primary IMAP account ──────────────────────────────── + $sJson = $this->loadPrimaryAccount($sSsoEmail); + if (!$sJson) { + // First visit: no stored account → show login dialog + return; + } + + $aAccount = json_decode($sJson, true); + if (empty($aAccount['email']) || empty($aAccount['pass_enc'])) { + return; + } + + $sPassword = \SnappyMail\Crypt::DecryptFromJSON( + $aAccount['pass_enc'], + APP_SALT . $sSsoEmail + ); + if (!$sPassword) { + return; + } + + // ── Create a one-time SSO hash and redirect ────────────────────────── + $sSsoHash = \RainLoop\Api::CreateUserSsoHash($aAccount['email'], $sPassword); + if ($sSsoHash) { + // Record the SSO identity for this session before the auto-login redirect + $this->setSsoUserCookie($sSsoEmail); + // Mark that an auto-login attempt is in flight (breaks loops on failure) + setcookie(self::COOKIE_SSO_PENDING, '1', time() + 120, '/', '', true, true); + \MailSo\Base\Http::Location('/?sso&hash=' . urlencode($sSsoHash)); + exit; + } + } + + /** + * Hook: login.credentials.step-2 + * + * Fires when the login form is submitted (via DoLogin XHR). + * We force the e-mail to the SSO identity so the user only needs + * to enter the IMAP password. + * + * @param string $sEmail (by reference) + * @param string $sPassword (by reference, not modified here) + */ + public function onLoginCredentialsStep2(string &$sEmail, string &$sPassword): void + { + $sSsoEmail = $this->getSsoEmail(); + if (!$sSsoEmail) { + return; + } + // Only override the email for the primary (first) login. + // When a user adds an additional account, a primary account is already + // active — in that case we must not touch the typed email. + if (\RainLoop\Api::Actions()->getAccountFromToken(false)) { + return; + } + // Override whatever the user typed with the proxy-verified identity + $sEmail = $sSsoEmail; + } + + /** + * Hook: login.success + * + * Fires after a successful IMAP connect + login. + * We persist the credentials (encrypted) for future auto-logins. + */ + public function onLoginSuccess(\RainLoop\Model\Account $oAccount): void + { + $sSsoEmail = $this->getSsoEmail(); + if (!$sSsoEmail) { + return; + } + + $sPassword = $oAccount->ImapPass(); + if (!$sPassword) { + return; + } + + $sEncPass = \SnappyMail\Crypt::EncryptToJSON($sPassword, APP_SALT . $sSsoEmail); + if (!$sEncPass) { + return; + } + + $aData = [ + 'email' => $oAccount->Email(), + 'imap_user' => $oAccount->ImapUser(), + 'pass_enc' => $sEncPass, + ]; + + $this->storePrimaryAccount($sSsoEmail, json_encode($aData)); + + // Record which SSO identity owns this session so we can detect a + // user-switch on the next request (see onFilterHttpPaths). + $this->setSsoUserCookie($sSsoEmail); + + // Clear the loop-guard cookie (auto-login now has fresh credentials) + $this->clearPendingCookie(); + } + + // ───────────────────────────────────────────────────────────────────────── + // Helpers + // ───────────────────────────────────────────────────────────────────────── + + /** Returns the SSO e-mail from the configured header, lowercased and trimmed. */ + private function getSsoEmail(): string + { + $sHeader = trim($this->Config()->Get('plugin', 'header_name', 'Remote-Email')); + // Convert "Remote-Email" → "HTTP_REMOTE_EMAIL" (PHP $_SERVER key format) + $sKey = 'HTTP_' . strtoupper(str_replace('-', '_', $sHeader)); + return strtolower(trim($_SERVER[$sKey] ?? '')); + } + + /** + * Returns the storage file path for the given SSO e-mail. + * Path: APP_PRIVATE_DATA/storage/_sso_/{sanitized-email}/primary.json + * + * @param bool $bMkDir Create the directory if it does not exist + */ + private function getSsoStoragePath(string $sSsoEmail, bool $bMkDir = false): string + { + // Keep printable chars that are safe in directory names; replace others with _ + $sSanitized = preg_replace('/[^a-zA-Z0-9._@\-]/', '_', $sSsoEmail); + $sDir = APP_PRIVATE_DATA . 'storage/_sso_/' . $sSanitized . '/'; + if ($bMkDir && !is_dir($sDir)) { + mkdir($sDir, 0700, true); + } + return $sDir . 'primary.json'; + } + + /** Reads and returns the raw JSON string for the primary account, or null. */ + private function loadPrimaryAccount(string $sSsoEmail): ?string + { + $sPath = $this->getSsoStoragePath($sSsoEmail); + if (is_readable($sPath)) { + $sData = file_get_contents($sPath); + return ($sData !== false && $sData !== '') ? $sData : null; + } + return null; + } + + /** Writes the raw JSON string for the primary account. */ + private function storePrimaryAccount(string $sSsoEmail, string $sJson): bool + { + $sPath = $this->getSsoStoragePath($sSsoEmail, true); + return file_put_contents($sPath, $sJson, LOCK_EX) !== false; + } + + /** Expires the loop-guard cookie. */ + private function clearPendingCookie(): void + { + setcookie(self::COOKIE_SSO_PENDING, '', time() - 3600, '/', '', true, true); + } + + /** + * Sets the SSO-user cookie to the SHA-1 of the given e-mail. + * Lasts for 30 days; refreshed on every auto-login. + */ + private function setSsoUserCookie(string $sSsoEmail): void + { + setcookie(self::COOKIE_SSO_USER, sha1($sSsoEmail), time() + 86400 * 30, '/', '', true, true); + } +}