Skip to content

Commit c0f4519

Browse files
committed
feat: enforce strict OIDC email binding by default and improve admin UX
1 parent b5ba568 commit c0f4519

7 files changed

Lines changed: 82 additions & 4 deletions

File tree

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ OIDC_CLIENT_SECRET=
2020
OIDC_REDIRECT_URI=http://localhost:8080/plugins/universal_oidc_mail_sso/callback_bridge.php
2121
OIDC_POST_LOGOUT_REDIRECT_URI=http://localhost:8080/
2222
ALLOWED_EMAIL_DOMAIN=
23+
ALLOW_CUSTOM_MAILBOX_EMAIL=false
2324

2425
# 32 random bytes base64; generate with:
2526
# openssl rand -base64 32

docker-compose.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ services:
4444
OIDC_POST_LOGOUT_REDIRECT_URI: ${OIDC_POST_LOGOUT_REDIRECT_URI}
4545
OIDC_SCOPES: ${OIDC_SCOPES}
4646
ALLOWED_EMAIL_DOMAIN: ${ALLOWED_EMAIL_DOMAIN}
47+
ALLOW_CUSTOM_MAILBOX_EMAIL: ${ALLOW_CUSTOM_MAILBOX_EMAIL}
4748
ADMIN_GROUP_NAME: ${ADMIN_GROUP_NAME}
4849
ADMIN_EMAILS: ${ADMIN_EMAILS}
4950
RCUBE_MAILBOX_KEY: ${RCUBE_MAILBOX_KEY}

plugins/universal_oidc_mail_sso/README.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,9 @@ Roundcube plugin for:
1111
- OIDC discovery from `OIDC_ISSUER`
1212
- PKCE (`S256`) + `state` + `nonce`
1313
- ID token validation via JWKS (`RS256`)
14+
- Strong identity binding by default:
15+
- mailbox email is locked to the verified OIDC `email` claim
16+
- admins can optionally allow custom mailbox email overrides
1417
- Required claim mapping:
1518
- stable user id: `sub`
1619
- email/login id: `email` (required)
@@ -25,6 +28,11 @@ Roundcube plugin for:
2528
- Built-in IMAP/SMTP credential test before saving profile
2629
- Audit log table for authentication/provisioning events
2730
- Admin dashboard for mapped accounts + audit history (restricted by OIDC group)
31+
- Admin controls for lifecycle/security:
32+
- enable/disable mapped users
33+
- delete mapped users
34+
- restrict allowed email domains and IMAP/SMTP hosts
35+
- toggle strict OIDC-email binding policy
2836
- Session-based setup form throttle + CSRF protection
2937
- Reverse proxy aware redirect URI construction (`X-Forwarded-Proto`, `X-Forwarded-Host`, `FORCE_HTTPS`)
3038

@@ -60,6 +68,7 @@ Optional:
6068
- `OIDC_REDIRECT_URI` (if omitted, plugin computes from request/proxy headers)
6169
- `OIDC_POST_LOGOUT_REDIRECT_URI`
6270
- `ALLOWED_EMAIL_DOMAIN` (optional; empty or `*` allows all domains)
71+
- `ALLOW_CUSTOM_MAILBOX_EMAIL=false` (default strict binding to OIDC email)
6372
- `FORCE_HTTPS=true`
6473
- `DISABLE_PASSWORD_LOGIN=true`
6574
- `ADMIN_GROUP_NAME=webmail_admin`
@@ -184,6 +193,7 @@ Covers:
184193
- Decrypted credentials are used only in memory at auth/connect time.
185194
- Logs intentionally avoid secrets/tokens/passwords.
186195
- Missing claim/domain mismatch/missing mailbox/decrypt failures fail closed.
196+
- Mailbox email binding is strict by default (OIDC `email` must match setup email).
187197
- CSRF token enforced on connect form submission.
188198
- Basic session-based rate limit applied to setup submissions.
189199

plugins/universal_oidc_mail_sso/config.inc.php.dist

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ $config['universal_oidc_mail_sso_redirect_uri'] = getenv('OIDC_REDIRECT_URI') ?:
88
$config['universal_oidc_mail_sso_post_logout_redirect_uri'] = getenv('OIDC_POST_LOGOUT_REDIRECT_URI') ?: null;
99
$config['universal_oidc_mail_sso_scopes'] = getenv('OIDC_SCOPES') ?: 'openid email profile groups';
1010
$config['universal_oidc_mail_sso_allowed_email_domain'] = getenv('ALLOWED_EMAIL_DOMAIN') ?: '';
11+
$config['universal_oidc_mail_sso_allow_custom_mailbox_email'] = getenv('ALLOW_CUSTOM_MAILBOX_EMAIL') ?: false;
1112

1213
// Security / behavior
1314
$config['universal_oidc_mail_sso_mailbox_key'] = getenv('RCUBE_MAILBOX_KEY') ?: '';

plugins/universal_oidc_mail_sso/skins/elastic/templates/admin_dashboard.html

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
th, td { border:1px solid #d8e0ea; padding:6px 8px; text-align:left; vertical-align:top; }
1515
th { background:#f6f8fb; }
1616
.hint { color:#5d6c7c; font-size:12px; margin-top:8px; }
17+
.info { margin-top:10px; background:#f2f7ff; border:1px solid #bcd3ff; border-radius:8px; padding:10px 12px; }
18+
.info ul { margin:8px 0 0 18px; padding:0; color:#2f4760; font-size:13px; }
1719
label { display:block; font-size:13px; font-weight:600; margin:0 0 6px; }
1820
input { width:100%; box-sizing:border-box; padding:8px 10px; border:1px solid #c5cfda; border-radius:8px; font-size:13px; margin-bottom:8px; }
1921
button { background:#0b5fff; color:#fff; border:none; border-radius:8px; padding:10px 14px; font-size:13px; cursor:pointer; }
@@ -25,6 +27,15 @@
2527
<section class="card">
2628
<h1>SSO Admin Dashboard</h1>
2729
<div class="hint">Access is restricted to users in the <code>webmail_admin</code> group (configurable).</div>
30+
<div class="info">
31+
<strong>Universal OIDC Mail SSO capabilities</strong>
32+
<ul>
33+
<li>OIDC login with automatic mailbox profile mapping.</li>
34+
<li>Strict email identity binding by default (OIDC email only).</li>
35+
<li>Central policy controls for allowed email domains and IMAP/SMTP hosts.</li>
36+
<li>Audit trail and account lifecycle actions (disable/delete mappings).</li>
37+
</ul>
38+
</div>
2839
</section>
2940

3041
<section class="card">
@@ -41,6 +52,8 @@ <h2>Access Policies</h2>
4152
<label for="allowed_smtp_hosts">Allowed SMTP hosts (comma-separated, `*` = any)</label>
4253
<input id="allowed_smtp_hosts" name="allowed_smtp_hosts" type="text" value="{{allowed_smtp_hosts}}" placeholder="smtp.example.com,mail.example.org">
4354

55+
<label><input type="checkbox" name="allow_custom_mailbox_email" value="1" {{allow_custom_mailbox_email_checked}}> Allow users to override mailbox email (default: disabled/strict OIDC email binding)</label>
56+
4457
<button type="submit">Save Policies</button>
4558
</form>
4659
<div class="hint">These policies are enforced on setup and SSO autologin.</div>

plugins/universal_oidc_mail_sso/skins/elastic/templates/connect_mailbox.html

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,21 +19,32 @@
1919
.pizsso-success { background:#e9f9ef; border:1px solid #8ed6a4; color:#0f5f2b; border-radius:8px; padding:10px 12px; margin:0 0 14px; }
2020
.actions { display:flex; gap:10px; margin-top:16px; flex-wrap:wrap; }
2121
.actions button.secondary { background:#fff; color:#0b5fff; border:1px solid #8fb4ff; }
22+
.info { margin:0 0 14px; background:#f2f7ff; border:1px solid #bcd3ff; border-radius:8px; padding:10px 12px; }
23+
.info ul { margin:8px 0 0 18px; padding:0; color:#2f4760; font-size:13px; }
2224
@media (max-width: 720px) { .card { margin:20px 12px; } .grid { grid-template-columns:1fr; } }
2325
</style>
2426
</head>
2527
<body>
2628
<main class="card">
2729
<h1>Connect account</h1>
2830
<p>Enter your mailbox password (or app-password) once. It will be encrypted and reused for automatic login.</p>
31+
<div class="info">
32+
<strong>Why this setup is secure and convenient</strong>
33+
<ul>
34+
<li>By default, your mailbox email is locked to your verified OIDC email.</li>
35+
<li>Your mailbox password is encrypted before it is stored.</li>
36+
<li>After setup, login is passwordless through your OIDC provider.</li>
37+
</ul>
38+
</div>
2939
{{message}}
3040
<form method="post" action="{{form_action}}">
3141
<input type="hidden" name="_token" value="{{csrf_token}}">
3242

3343
<div class="grid">
3444
<div class="full">
3545
<label for="email">Email</label>
36-
<input id="email" name="email" type="email" required value="{{email}}">
46+
<input id="email" name="email" type="email" required value="{{email}}" {{email_readonly_attr}}>
47+
{{email_policy_hint}}
3748
</div>
3849

3950
<div class="full">
@@ -91,7 +102,7 @@ <h1>Connect account</h1>
91102
<button class="secondary" type="submit" name="intent" value="test">Test connection</button>
92103
<button type="submit" name="intent" value="save">Save and continue</button>
93104
</div>
94-
<div class="hint">No plaintext passwords are stored in the database.</div>
105+
<div class="hint">Admins can review mappings and enforce domain/host restrictions in the SSO admin dashboard.</div>
95106
</form>
96107
</main>
97108
</body>

plugins/universal_oidc_mail_sso/universal_oidc_mail_sso.php

Lines changed: 43 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,11 +71,27 @@ public function init(): void
7171

7272
public function startup(array $args): array
7373
{
74+
$action = rcube_utils::get_input_value('_action', rcube_utils::INPUT_GPC);
75+
76+
// If admin plugin endpoints are accessed directly without a Roundcube
77+
// session, start OIDC login flow instead of failing with access errors.
78+
if ($this->rc->task === 'settings'
79+
&& is_string($action)
80+
&& in_array($action, [
81+
self::ACTION_ADMIN,
82+
self::ACTION_ADMIN_SAVE_POLICY,
83+
self::ACTION_ADMIN_DELETE_USER,
84+
self::ACTION_ADMIN_SET_USER_STATUS,
85+
], true)
86+
&& empty($_SESSION['user_id'])
87+
) {
88+
$this->redirectTo($this->urlForAction(self::ACTION_LOGIN));
89+
}
90+
7491
if ($this->rc->task !== 'login' || !empty($_SESSION['user_id'])) {
7592
return $args;
7693
}
7794

78-
$action = rcube_utils::get_input_value('_action', rcube_utils::INPUT_GPC);
7995
if (is_string($action) && strpos($action, 'plugin.universal_oidc_mail_sso_') === 0) {
8096
// Fallback direct dispatcher for environments where register_action
8197
// doesn't route plugin actions in login task as expected.
@@ -461,7 +477,7 @@ public function actionSaveMailbox(): void
461477
return;
462478
}
463479

464-
if ($email !== strtolower((string) $oidcEmail)) {
480+
if ($this->isStrictOidcEmailBinding() && $email !== strtolower((string) $oidcEmail)) {
465481
$this->renderConnectMailboxPage('Email must match your OIDC account.');
466482
return;
467483
}
@@ -577,12 +593,17 @@ private function renderConnectMailboxPage(string $message, bool $isError = true,
577593
$imapSecurity = (string) ($values['imap_security'] ?? $this->cfg('default_imap_security', 'ssl'));
578594
$smtpSecurity = (string) ($values['smtp_security'] ?? $this->cfg('default_smtp_security', 'tls'));
579595
$smtpAuthChecked = (string) ($values['smtp_auth'] ?? (((string) $this->cfg('default_smtp_auth', '1') !== '0') ? '1' : '0'));
596+
$strictEmailBinding = $this->isStrictOidcEmailBinding();
580597

581598
$replacements = [
582599
'{{form_action}}' => htmlspecialchars($this->rc->url(['task' => 'login', 'action' => self::ACTION_SAVE_MAILBOX]), ENT_QUOTES, 'UTF-8'),
583600
'{{csrf_token}}' => htmlspecialchars(rcmail::get_instance()->get_request_token(), ENT_QUOTES, 'UTF-8'),
584601
'{{message}}' => $message !== '' ? '<div class="' . ($isError ? 'pizsso-error' : 'pizsso-success') . '">' . htmlspecialchars($message, ENT_QUOTES, 'UTF-8') . '</div>' : '',
585602
'{{email}}' => htmlspecialchars($email, ENT_QUOTES, 'UTF-8'),
603+
'{{email_readonly_attr}}' => $strictEmailBinding ? 'readonly' : '',
604+
'{{email_policy_hint}}' => $strictEmailBinding
605+
? '<div class="hint">Email is locked to your verified OIDC account by admin policy.</div>'
606+
: '<div class="hint">Admin policy allows overriding mailbox email.</div>',
586607
'{{imap_host}}' => htmlspecialchars((string) ($values['imap_host'] ?? $this->cfg('default_imap_host', 'imap.example.com')), ENT_QUOTES, 'UTF-8'),
587608
'{{imap_port}}' => htmlspecialchars((string) ($values['imap_port'] ?? $this->cfg('default_imap_port', '993')), ENT_QUOTES, 'UTF-8'),
588609
'{{imap_security_ssl_selected}}' => ($imapSecurity === 'ssl') ? 'selected' : '',
@@ -700,6 +721,7 @@ private function getPolicies(): array
700721
'allowed_email_domains',
701722
'allowed_imap_hosts',
702723
'allowed_smtp_hosts',
724+
'allow_custom_mailbox_email',
703725
]);
704726

705727
return $this->policyCache;
@@ -913,6 +935,18 @@ private function isAdminUser(): bool
913935
return false;
914936
}
915937

938+
private function isStrictOidcEmailBinding(): bool
939+
{
940+
$policies = $this->getPolicies();
941+
$policyVal = strtolower(trim((string) ($policies['allow_custom_mailbox_email'] ?? '')));
942+
if ($policyVal !== '') {
943+
return !in_array($policyVal, ['1', 'true', 'yes', 'on'], true);
944+
}
945+
946+
// Strict by default unless explicitly disabled.
947+
return !$this->cfgBool('allow_custom_mailbox_email', false);
948+
}
949+
916950
public function actionAdminDashboard(): void
917951
{
918952
if ($this->rc->task !== 'settings') {
@@ -993,6 +1027,10 @@ public function actionAdminDashboard(): void
9931027
'{{allowed_domains}}' => htmlspecialchars((string) ($policies['allowed_email_domains'] ?? ''), ENT_QUOTES, 'UTF-8'),
9941028
'{{allowed_imap_hosts}}' => htmlspecialchars((string) ($policies['allowed_imap_hosts'] ?? ''), ENT_QUOTES, 'UTF-8'),
9951029
'{{allowed_smtp_hosts}}' => htmlspecialchars((string) ($policies['allowed_smtp_hosts'] ?? ''), ENT_QUOTES, 'UTF-8'),
1030+
'{{allow_custom_mailbox_email_checked}}' => !empty($policies['allow_custom_mailbox_email'])
1031+
&& in_array(strtolower((string) $policies['allow_custom_mailbox_email']), ['1', 'true', 'yes', 'on'], true)
1032+
? 'checked'
1033+
: '',
9961034
]);
9971035

9981036
header('Content-Type: text/html; charset=UTF-8');
@@ -1015,10 +1053,12 @@ public function actionAdminSavePolicy(): void
10151053
$allowedDomains = trim((string) rcube_utils::get_input_value('allowed_email_domains', rcube_utils::INPUT_POST));
10161054
$allowedImapHosts = trim((string) rcube_utils::get_input_value('allowed_imap_hosts', rcube_utils::INPUT_POST));
10171055
$allowedSmtpHosts = trim((string) rcube_utils::get_input_value('allowed_smtp_hosts', rcube_utils::INPUT_POST));
1056+
$allowCustomMailboxEmail = rcube_utils::get_input_value('allow_custom_mailbox_email', rcube_utils::INPUT_POST) ? '1' : '0';
10181057

10191058
$this->storage->setPolicy('allowed_email_domains', $allowedDomains);
10201059
$this->storage->setPolicy('allowed_imap_hosts', $allowedImapHosts);
10211060
$this->storage->setPolicy('allowed_smtp_hosts', $allowedSmtpHosts);
1061+
$this->storage->setPolicy('allow_custom_mailbox_email', $allowCustomMailboxEmail);
10221062
$this->policyCache = null;
10231063

10241064
$this->audit('admin_policy', 'ok', 'policy updated');
@@ -1163,6 +1203,7 @@ private function cfg(string $name, ?string $default = null): ?string
11631203
'redirect_uri' => 'OIDC_REDIRECT_URI',
11641204
'post_logout_redirect_uri' => 'OIDC_POST_LOGOUT_REDIRECT_URI',
11651205
'allowed_email_domain' => 'ALLOWED_EMAIL_DOMAIN',
1206+
'allow_custom_mailbox_email' => 'ALLOW_CUSTOM_MAILBOX_EMAIL',
11661207
'mailbox_key' => 'RCUBE_MAILBOX_KEY',
11671208
'force_https' => 'FORCE_HTTPS',
11681209
'disable_password_login' => 'DISABLE_PASSWORD_LOGIN',

0 commit comments

Comments
 (0)