Skip to content

Commit 52c4582

Browse files
feat: Auth - OAuth2 (Dovecot PassDB) (docker-mailserver#3480)
Co-authored-by: Brennan Kinney <[email protected]>
1 parent 06fab3f commit 52c4582

File tree

17 files changed

+278
-2
lines changed

17 files changed

+278
-2
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
#################################################
44

55
.env
6+
compose.override.yaml
67
docs/site/
78
docker-data/
89

CHANGELOG.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,13 @@ All notable changes to this project will be documented in this file. The format
66

77
> **Note**: Changes and additions listed here are contained in the `:edge` image tag. These changes may not be as stable as released changes.
88
9+
### Features
10+
11+
- **Authentication with OIDC / OAuth 2.0** 🎉
12+
- DMS now supports authentication via OAuth2 (_via `XOAUTH2` or `OAUTHBEARER` SASL mechanisms_) from capable services (_like Roundcube_).
13+
- This does not replace the need for an `ACCOUNT_PROVISIONER` (`FILE` / `LDAP`), which is required for an account to receive or send mail.
14+
- Successful authentication (_via Dovecot PassDB_) still requires an existing account (_lookup via Dovecot UserDB_).
15+
916
### Updates
1017

1118
- **Tests**:

Dockerfile

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,13 @@ EOF
108108
COPY target/rspamd/local.d/ /etc/rspamd/local.d/
109109
COPY target/rspamd/scores.d/* /etc/rspamd/scores.d/
110110

111+
# -----------------------------------------------
112+
# --- OAUTH2 ------------------------------------
113+
# -----------------------------------------------
114+
115+
COPY target/dovecot/auth-oauth2.conf.ext /etc/dovecot/conf.d
116+
COPY target/dovecot/dovecot-oauth2.conf.ext /etc/dovecot
117+
111118
# -----------------------------------------------
112119
# --- LDAP & SpamAssassin's Cron ----------------
113120
# -----------------------------------------------

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,3 +48,4 @@ If you have issues, please search through [the documentation][documentation::web
4848
- Support for [LetsEncrypt](https://letsencrypt.org/), manual and self-signed certificates
4949
- A [setup script](https://docker-mailserver.github.io/docker-mailserver/latest/config/setup.sh) for easy configuration and maintenance
5050
- SASLauthd with LDAP authentication
51+
- OAuth2 authentication (_via `XOAUTH2` or `OAUTHBEARER` SASL mechanisms_)
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
---
2+
title: 'Advanced | Basic OAuth2 Authentication'
3+
---
4+
5+
## Introduction
6+
7+
!!! warning "This is only a supplement to the existing account provisioners"
8+
9+
Accounts must still be managed via the configured [`ACCOUNT_PROVISIONER`][env::account-provisioner] (FILE or LDAP).
10+
11+
Reasoning for this can be found in [#3480][gh-pr::oauth2]. Future iterations on this feature may allow it to become a full account provisioner.
12+
13+
[gh-pr::oauth2]: https://github.com/docker-mailserver/docker-mailserver/pull/3480
14+
[env::account-provisioner]: ../environment.md#account_provisioner
15+
16+
The present OAuth2 support provides the capability for 3rd-party applications such as Roundcube to authenticate with DMS (dovecot) by using a token obtained from an OAuth2 provider, instead of passing passwords around.
17+
18+
## Example (Authentik & Roundcube)
19+
20+
This example assumes you have:
21+
22+
- A working DMS server set up
23+
- An Authentik server set up ([documentation](https://goauthentik.io/docs/installation/))
24+
- A Roundcube server set up (either [docker](https://hub.docker.com/r/roundcube/roundcubemail/) or [bare metal](https://github.com/roundcube/roundcubemail/wiki/Installation))
25+
26+
!!! example "Setup Instructions"
27+
28+
=== "1. Docker Mailserver"
29+
Edit the following values in `mailserver.env`:
30+
```env
31+
# -----------------------------------------------
32+
# --- OAUTH2 Section ----------------------------
33+
# -----------------------------------------------
34+
35+
# empty => OAUTH2 authentication is disabled
36+
# 1 => OAUTH2 authentication is enabled
37+
ENABLE_OAUTH2=1
38+
39+
# Specify the user info endpoint URL of the oauth2 provider
40+
OAUTH2_INTROSPECTION_URL=https://authentik.example.com/application/o/userinfo/
41+
```
42+
43+
=== "2. Authentik"
44+
1. Create a new OAuth2 provider
45+
2. Note the client id and client secret
46+
3. Set the allowed redirect url to the equivalent of `https://roundcube.example.com/index.php/login/oauth` for your RoundCube instance.
47+
48+
=== "3. Roundcube"
49+
Add the following to `oauth2.inc.php` ([documentation](https://github.com/roundcube/roundcubemail/wiki/Configuration)):
50+
51+
```php
52+
$config['oauth_provider'] = 'generic';
53+
$config['oauth_provider_name'] = 'Authentik';
54+
$config['oauth_client_id'] = '<insert client id here>';
55+
$config['oauth_client_secret'] = '<insert client secret here>';
56+
$config['oauth_auth_uri'] = 'https://authentik.example.com/application/o/authorize/';
57+
$config['oauth_token_uri'] = 'https://authentik.example.com/application/o/token/';
58+
$config['oauth_identity_uri'] = 'https://authentik.example.com/application/o/userinfo/';
59+
60+
// Optional: disable SSL certificate check on HTTP requests to OAuth server. For possible values, see:
61+
// http://docs.guzzlephp.org/en/stable/request-options.html#verify
62+
$config['oauth_verify_peer'] = false;
63+
64+
$config['oauth_scope'] = 'email openid profile';
65+
$config['oauth_identity_fields'] = ['email'];
66+
67+
// Boolean: automatically redirect to OAuth login when opening Roundcube without a valid session
68+
$config['oauth_login_redirect'] = false;
69+
```

docs/content/config/environment.md

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,15 @@ The Group ID assigned to the static vmail group for `/var/mail` (_Mail storage m
5454

5555
Configures the provisioning source of user accounts (including aliases) for user queries and authentication by services managed by DMS (_Postfix and Dovecot_).
5656

57-
User provisioning via OIDC is planned for the future, see [this tracking issue](https://github.com/docker-mailserver/docker-mailserver/issues/2713).
57+
!!! tip "OAuth2 Support"
58+
59+
Presently DMS supports OAuth2 only as an supplementary authentication method.
60+
61+
- A third-party service must provide a valid token for the user which Dovecot validates with the authentication service provider. To enable this feature reference the [OAuth2 configuration example guide][docs::auth::oauth2-config-guide].
62+
- User accounts must be provisioned to receive mail via one of the supported `ACCOUNT_PROVISIONER` providers.
63+
- User provisioning via OIDC is planned for the future, see [this tracking issue](https://github.com/docker-mailserver/docker-mailserver/issues/2713).
64+
65+
[docs::auth::oauth2-config-guide]: ./advanced/auth-oauth2.md
5866

5967
- **empty** => use FILE
6068
- LDAP => use LDAP authentication
@@ -716,9 +724,19 @@ Enable or disable `getmail`.
716724

717725
- **5** => `getmail` The number of minutes for the interval. Min: 1; Max: 30; Default: 5.
718726

719-
#### LDAP
720727

728+
#### OAUTH2
729+
730+
##### ENABLE_OAUTH2
721731

732+
- **empty** => OAUTH2 authentication is disabled
733+
- 1 => OAUTH2 authentication is enabled
734+
735+
##### OAUTH2_INTROSPECTION_URL
736+
737+
- => Specify the user info endpoint URL of the oauth2 provider (_eg: `https://oauth2.example.com/userinfo/`_)
738+
739+
#### LDAP
722740

723741
##### LDAP_START_TLS
724742

docs/mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,7 @@ nav:
142142
- 'Postfix': config/advanced/override-defaults/postfix.md
143143
- 'Modifications via Script': config/advanced/override-defaults/user-patches.md
144144
- 'LDAP Authentication': config/advanced/auth-ldap.md
145+
- 'OAuth2 Authentication': config/advanced/auth-oauth2.md
145146
- 'Email Filtering with Sieve': config/advanced/mail-sieve.md
146147
- 'Email Gathering with Fetchmail': config/advanced/mail-fetchmail.md
147148
- 'Email Gathering with Getmail': config/advanced/mail-getmail.md

mailserver.env

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -419,6 +419,18 @@ ENABLE_GETMAIL=0
419419
# The number of minutes for the interval. Min: 1; Max: 30.
420420
GETMAIL_POLL=5
421421

422+
# -----------------------------------------------
423+
# --- OAUTH2 Section ----------------------------
424+
# -----------------------------------------------
425+
426+
# empty => OAUTH2 authentication is disabled
427+
# 1 => OAUTH2 authentication is enabled
428+
ENABLE_OAUTH2=
429+
430+
# Specify the user info endpoint URL of the oauth2 provider
431+
# Example: https://oauth2.example.com/userinfo/
432+
OAUTH2_INTROSPECTION_URL=
433+
422434
# -----------------------------------------------
423435
# --- LDAP Section ------------------------------
424436
# -----------------------------------------------

target/dovecot/10-auth.conf

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,7 @@ auth_mechanisms = plain login
123123
#!include auth-sql.conf.ext
124124
#!include auth-ldap.conf.ext
125125
!include auth-passwdfile.inc
126+
#!include auth-oauth2.conf.ext
126127
#!include auth-checkpassword.conf.ext
127128
#!include auth-vpopmail.conf.ext
128129
#!include auth-static.conf.ext

target/dovecot/auth-oauth2.conf.ext

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
auth_mechanisms = $auth_mechanisms oauthbearer xoauth2
2+
3+
passdb {
4+
driver = oauth2
5+
mechanisms = xoauth2 oauthbearer
6+
args = /etc/dovecot/dovecot-oauth2.conf.ext
7+
}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
introspection_url =
2+
# Dovecot defaults:
3+
introspection_mode = auth
4+
username_attribute = email

target/scripts/start-mailserver.sh

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,11 @@ function _register_functions() {
7171
;;
7272
esac
7373

74+
if [[ ${ENABLE_OAUTH2} -eq 1 ]]; then
75+
_environment_variables_oauth2
76+
_register_setup_function '_setup_oauth2'
77+
fi
78+
7479
if [[ ${ENABLE_SASLAUTHD} -eq 1 ]]; then
7580
_environment_variables_saslauthd
7681
_register_setup_function '_setup_saslauthd'
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
#!/bin/bash
2+
3+
function _setup_oauth2() {
4+
_log 'debug' 'Setting up OAUTH2'
5+
6+
# Enable OAuth2 PassDB (Authentication):
7+
sedfile -i -e '/\!include auth-oauth2\.conf\.ext/s/^#//' /etc/dovecot/conf.d/10-auth.conf
8+
_replace_by_env_in_file 'OAUTH2_' '/etc/dovecot/dovecot-oauth2.conf.ext'
9+
10+
return 0
11+
}

target/scripts/startup/variables-stack.sh

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -151,6 +151,12 @@ function __environment_variables_general_setup() {
151151
VARS[UPDATE_CHECK_INTERVAL]="${UPDATE_CHECK_INTERVAL:=1d}"
152152
}
153153

154+
function _environment_variables_oauth2() {
155+
_log 'debug' 'Setting OAUTH2-related environment variables now'
156+
157+
VARS[OAUTH2_INTROSPECTION_URL]="${OAUTH2_INTROSPECTION_URL:=}"
158+
}
159+
154160
# This function handles environment variables related to LDAP.
155161
# NOTE: SASLAuthd and Dovecot LDAP support inherit these common ENV.
156162
function _environment_variables_ldap() {

test/config/oauth2/provider.py

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
# OAuth2 mock service
2+
#
3+
# Dovecot will query this service with the token it was provided.
4+
# If the session for the token is valid, a response provides an attribute to perform a UserDB lookup on (default: email).
5+
6+
import json
7+
import base64
8+
from http.server import BaseHTTPRequestHandler, HTTPServer
9+
10+
# OAuth2.0 Bearer token (paste into https://jwt.io/ to check it's contents).
11+
# You should never need to edit this unless you REALLY need to change the issuer.
12+
token = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwOi8vcHJvdmlkZXIuZXhhbXBsZS50ZXN0OjgwMDAvIiwic3ViIjoiODJjMWMzMzRkY2M2ZTMxMWFlNGFhZWJmZTk0NmM1ZTg1OGYwNTVhZmYxY2U1YTM3YWE3Y2M5MWFhYjE3ZTM1YyIsImF1ZCI6Im1haWxzZXJ2ZXIiLCJ1aWQiOiI4OU4zR0NuN1M1Y090WkZNRTVBeVhNbmxURFdVcnEzRmd4YWlyWWhFIn0.zuCytArbphhJn9XT_y9cBdGqDCNo68tBrtOwPIsuKNyF340SaOuZa0xarZofygytdDpLtYr56QlPTKImi-n1ZWrHkRZkwrQi5jQ-j_n2hEAL0vUToLbDnXYfc5q2w7z7X0aoCmiK8-fV7Kx4CVTM7riBgpElf6F3wNAIcX6R1ijUh6ISCL0XYsdogf8WUNZipXY-O4R7YHXdOENuOp3G48hWhxuUh9PsUqE5yxDwLsOVzCTqg9S5gxPQzF2eCN9J0I2XiIlLKvLQPIZ2Y_K7iYvVwjpNdgb4xhm9wuKoIVinYkF_6CwIzAawBWIDJAbix1IslkUPQMGbupTDtOgTiQ"
13+
14+
# This is the string the user-facing client (e.g. Roundcube) should send via IMAP to Dovecot.
15+
# We include the user and the above token separated by '\1' chars as per the XOAUTH2 spec.
16+
xoauth2 = base64.b64encode(f"[email protected]\1auth=Bearer {token}\1\1".encode("utf-8"))
17+
# If changing the user above, use the new output from the below line with the contents of the AUTHENTICATE command in test/test-files/auth/imap-oauth2-auth.txt
18+
print("XOAUTH2 string: " + str(xoauth2))
19+
20+
21+
class HTTPRequestHandler(BaseHTTPRequestHandler):
22+
def do_GET(self):
23+
auth = self.headers.get("Authorization")
24+
if auth is None:
25+
self.send_response(401)
26+
self.end_headers()
27+
return
28+
if len(auth.split()) != 2:
29+
self.send_response(401)
30+
self.end_headers()
31+
return
32+
auth = auth.split()[1]
33+
# Valid session, respond with JSON containing the expected `email` claim to match as Dovecot username:
34+
if auth == token:
35+
self.send_response(200)
36+
self.send_header('Content-Type', 'application/json')
37+
self.end_headers()
38+
self.wfile.write(json.dumps({
39+
"email": "[email protected]",
40+
"email_verified": True,
41+
"sub": "82c1c334dcc6e311ae4aaebfe946c5e858f055aff1ce5a37aa7cc91aab17e35c"
42+
}).encode("utf-8"))
43+
else:
44+
self.send_response(401)
45+
self.end_headers()
46+
47+
server = HTTPServer(('', 80), HTTPRequestHandler)
48+
print("Starting server", flush=True)
49+
50+
try:
51+
server.serve_forever()
52+
except KeyboardInterrupt:
53+
print()
54+
print("Received keyboard interrupt")
55+
finally:
56+
print("Exiting")

test/files/auth/imap-oauth2-auth.txt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
a0 NOOP See test/config/oauth2/provider.py to generate the below XOAUTH2 string
2+
a1 AUTHENTICATE XOAUTH2 dXNlcj11c2VyMUBsb2NhbGhvc3QubG9jYWxkb21haW4BYXV0aD1CZWFyZXIgZXlKaGJHY2lPaUpTVXpJMU5pSXNJblI1Y0NJNklrcFhWQ0o5LmV5SnBjM01pT2lKb2RIUndPaTh2Y0hKdmRtbGtaWEl1WlhoaGJYQnNaUzUwWlhOME9qZ3dNREF2SWl3aWMzVmlJam9pT0RKak1XTXpNelJrWTJNMlpUTXhNV0ZsTkdGaFpXSm1aVGswTm1NMVpUZzFPR1l3TlRWaFptWXhZMlUxWVRNM1lXRTNZMk01TVdGaFlqRTNaVE0xWXlJc0ltRjFaQ0k2SW0xaGFXeHpaWEoyWlhJaUxDSjFhV1FpT2lJNE9VNHpSME51TjFNMVkwOTBXa1pOUlRWQmVWaE5ibXhVUkZkVmNuRXpSbWQ0WVdseVdXaEZJbjAuenVDeXRBcmJwaGhKbjlYVF95OWNCZEdxRENObzY4dEJydE93UElzdUtOeUYzNDBTYU91WmEweGFyWm9meWd5dGREcEx0WXI1NlFsUFRLSW1pLW4xWldySGtSWmt3clFpNWpRLWpfbjJoRUFMMHZVVG9MYkRuWFlmYzVxMnc3ejdYMGFvQ21pSzgtZlY3S3g0Q1ZUTTdyaUJncEVsZjZGM3dOQUljWDZSMWlqVWg2SVNDTDBYWXNkb2dmOFdVTlppcFhZLU80UjdZSFhkT0VOdU9wM0c0OGhXaHh1VWg5UHNVcUU1eXhEd0xzT1Z6Q1RxZzlTNWd4UFF6RjJlQ045SjBJMlhpSWxMS3ZMUVBJWjJZX0s3aVl2VndqcE5kZ2I0eGhtOXd1S29JVmluWWtGXzZDd0l6QWF3QldJREpBYml4MUlzbGtVUFFNR2J1cFREdE9nVGlRAQE=
3+
a2 EXAMINE INBOX
4+
a3 LOGOUT
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
load "${REPOSITORY_ROOT}/test/helper/setup"
2+
load "${REPOSITORY_ROOT}/test/helper/common"
3+
4+
BATS_TEST_NAME_PREFIX='[OAuth2] '
5+
CONTAINER1_NAME='dms-test_oauth2'
6+
CONTAINER2_NAME='dms-test_oauth2_provider'
7+
8+
function setup_file() {
9+
export DMS_TEST_NETWORK='test-network-oauth2'
10+
export DMS_DOMAIN='example.test'
11+
export FQDN_MAIL="mail.${DMS_DOMAIN}"
12+
export FQDN_OAUTH2="oauth2.${DMS_DOMAIN}"
13+
14+
# Link the test containers to separate network:
15+
# NOTE: If the network already exists, test will fail to start.
16+
docker network create "${DMS_TEST_NETWORK}"
17+
18+
# Setup local oauth2 provider service:
19+
docker run --rm -d --name "${CONTAINER2_NAME}" \
20+
--hostname "${FQDN_OAUTH2}" \
21+
--network "${DMS_TEST_NETWORK}" \
22+
--volume "${REPOSITORY_ROOT}/test/config/oauth2/:/app/" \
23+
docker.io/library/python:latest \
24+
python /app/provider.py
25+
26+
_run_until_success_or_timeout 20 sh -c "docker logs ${CONTAINER2_NAME} 2>&1 | grep 'Starting server'"
27+
28+
#
29+
# Setup DMS container
30+
#
31+
32+
# Add OAUTH2 configuration so that Dovecot can reach out to our mock provider (CONTAINER2)
33+
local ENV_OAUTH2_CONFIG=(
34+
--env ENABLE_OAUTH2=1
35+
--env OAUTH2_INTROSPECTION_URL=http://oauth2.example.test/userinfo/
36+
)
37+
38+
export CONTAINER_NAME=${CONTAINER1_NAME}
39+
local CUSTOM_SETUP_ARGUMENTS=(
40+
"${ENV_OAUTH2_CONFIG[@]}"
41+
42+
--hostname "${FQDN_MAIL}"
43+
--network "${DMS_TEST_NETWORK}"
44+
)
45+
46+
_init_with_defaults
47+
_common_container_setup 'CUSTOM_SETUP_ARGUMENTS'
48+
_wait_for_tcp_port_in_container 143
49+
50+
# Set default implicit container fallback for helpers:
51+
export CONTAINER_NAME=${CONTAINER1_NAME}
52+
}
53+
54+
function teardown_file() {
55+
docker rm -f "${CONTAINER1_NAME}" "${CONTAINER2_NAME}"
56+
docker network rm "${DMS_TEST_NETWORK}"
57+
}
58+
59+
60+
@test "oauth2: imap connect and authentication works" {
61+
# An initial connection needs to be made first, otherwise the auth attempt fails
62+
_run_in_container_bash 'nc -vz 0.0.0.0 143'
63+
64+
_nc_wrapper 'auth/imap-oauth2-auth.txt' '-w 1 0.0.0.0 143'
65+
assert_output --partial 'Examine completed'
66+
}

0 commit comments

Comments
 (0)