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 <noreply@anthropic.com>
This commit is contained in:
Markus Mauch 2026-03-23 10:55:57 +01:00
parent c154d23cfe
commit 7cb59eb627
2 changed files with 434 additions and 0 deletions

125
plugins/sso-auth/README.md Normal file
View file

@ -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.

309
plugins/sso-auth/index.php Normal file
View file

@ -0,0 +1,309 @@
<?php
/**
* SnappyMail SSO Auth Plugin
*
* Authenticates users transparently via a trusted reverse proxy that sets an
* HTTP header containing the authenticated user's e-mail address (e.g. Authelia,
* Caddy, Traefik, or any other forward-auth proxy).
*
* No master IMAP user required. First-time users enter their own IMAP password
* once; it is stored encrypted and reused for all future logins.
*
* Flow:
* 1. Reverse proxy authenticates the user and sets the configured header.
* 2. Plugin reads the header and looks up stored IMAP credentials.
* 3. If credentials exist create SSO hash auto-login (no dialog shown).
* 4. If no credentials yet show login form (e-mail pre-filled, password required).
* 5. After successful IMAP login credentials are stored encrypted for next time.
*
* Storage: APP_PRIVATE_DATA/storage/_sso_/{sanitized-email}/primary.json
*/
use RainLoop\Plugins\AbstractPlugin;
use RainLoop\Plugins\Property;
use RainLoop\Enumerations\PluginPropertyType;
class SsoAuthPlugin extends AbstractPlugin
{
const
NAME = 'SSO Auth',
AUTHOR = 'Markus Mauch',
URL = 'https://github.com/markusmauch',
VERSION = '1.0.0',
RELEASE = '2026-03-23',
REQUIRED = '2.36.0',
CATEGORY = 'Security',
LICENSE = 'MIT',
DESCRIPTION = 'Authenticates users via a trusted reverse proxy header (e.g. Authelia, Caddy, Traefik). '
. 'No master IMAP credentials required. IMAP password is entered once and stored encrypted.';
/** Cookie name used to break redirect loops on failed auto-login */
const COOKIE_SSO_PENDING = '_sso_pending';
/**
* Cookie that records which SSO identity owns the current SnappyMail session.
* Value: SHA-1 of the SSO e-mail, so the actual address is not exposed in the cookie.
* Set on every successful SSO login; cleared on logout.
*/
const COOKIE_SSO_USER = '_sso_user';
public function Init(): void
{
$this->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);
}
}