-
-
Notifications
You must be signed in to change notification settings - Fork 2k
feat: Auth - OAuth2 (Dovecot PassDB) #3480
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
0cd6243
e661240
a003caa
6ea253f
634983a
07b446c
4a6cfca
d1e40d9
facacb7
5591774
413ae1e
43f121d
fe837a0
c4e086b
58688a9
127b3e1
9c44199
b1cebc8
de388b1
cdcefe0
5295e91
a31fbf9
de24679
e62a870
9b7dda8
0c5d615
3ac93c1
ccfd43f
70b82e2
7facf79
6a80188
2a9a2c2
7d4db46
b01201f
5852ae5
5a6f2b1
b24096d
a13c8d4
acfc44d
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,7 @@ | |
| ################################################# | ||
|
|
||
| .env | ||
| compose.override.yaml | ||
| docs/site/ | ||
| docker-data/ | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| --- | ||
| title: 'Advanced | Basic OAuth2 Authentication' | ||
| --- | ||
|
|
||
| ## Introduction | ||
|
|
||
| !!! warning "This is only a supplement to the existing account provisioners" | ||
|
|
||
| Accounts must still be managed via the configured [`ACCOUNT_PROVISIONER`][env::account-provisioner] (FILE or LDAP). | ||
|
|
||
| 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. | ||
|
|
||
| [gh-pr::oauth2]: https://github.com/docker-mailserver/docker-mailserver/pull/3480 | ||
| [env::account-provisioner]: ../environment.md#account_provisioner | ||
|
|
||
| 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. | ||
|
|
||
|
Comment on lines
+16
to
+17
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We should perhaps raise awareness here about relevant config.
Moving away from ENV config, we would document the basics we're familiar with for that Dovecot file. We could optionally mention the Docker Compose I can contribute this in a follow-up PR that drops ENV implementation (we'll just merge this PR and address that before release in the follow-up). |
||
| ## Example (Authentik & Roundcube) | ||
|
keval6b marked this conversation as resolved.
|
||
|
|
||
| This example assumes you have: | ||
|
|
||
| - A working DMS server set up | ||
| - An Authentik server set up ([documentation](https://goauthentik.io/docs/installation/)) | ||
| - A Roundcube server set up (either [docker](https://hub.docker.com/r/roundcube/roundcubemail/) or [bare metal](https://github.com/roundcube/roundcubemail/wiki/Installation)) | ||
|
|
||
| !!! example "Setup Instructions" | ||
|
|
||
| === "1. Docker Mailserver" | ||
| Edit the following values in `mailserver.env`: | ||
| ```env | ||
| # ----------------------------------------------- | ||
| # --- OAUTH2 Section ---------------------------- | ||
| # ----------------------------------------------- | ||
|
|
||
| # empty => OAUTH2 authentication is disabled | ||
| # 1 => OAUTH2 authentication is enabled | ||
| ENABLE_OAUTH2=1 | ||
|
|
||
| # Specify the user info endpoint URL of the oauth2 provider | ||
| OAUTH2_INTROSPECTION_URL=https://authentik.example.com/application/o/userinfo/ | ||
| ``` | ||
|
|
||
| === "2. Authentik" | ||
| 1. Create a new OAuth2 provider | ||
| 2. Note the client id and client secret | ||
| 3. Set the allowed redirect url to the equivalent of `https://roundcube.example.com/index.php/login/oauth` for your RoundCube instance. | ||
|
|
||
| === "3. Roundcube" | ||
| Add the following to `oauth2.inc.php` ([documentation](https://github.com/roundcube/roundcubemail/wiki/Configuration)): | ||
|
|
||
| ```php | ||
| $config['oauth_provider'] = 'generic'; | ||
| $config['oauth_provider_name'] = 'Authentik'; | ||
| $config['oauth_client_id'] = '<insert client id here>'; | ||
| $config['oauth_client_secret'] = '<insert client secret here>'; | ||
| $config['oauth_auth_uri'] = 'https://authentik.example.com/application/o/authorize/'; | ||
| $config['oauth_token_uri'] = 'https://authentik.example.com/application/o/token/'; | ||
| $config['oauth_identity_uri'] = 'https://authentik.example.com/application/o/userinfo/'; | ||
|
|
||
| // Optional: disable SSL certificate check on HTTP requests to OAuth server. For possible values, see: | ||
| // http://docs.guzzlephp.org/en/stable/request-options.html#verify | ||
| $config['oauth_verify_peer'] = false; | ||
|
|
||
| $config['oauth_scope'] = 'email openid profile'; | ||
| $config['oauth_identity_fields'] = ['email']; | ||
|
Comment on lines
+64
to
+65
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Context comment (no action required) This is related to the implicit default for the Dovecot |
||
|
|
||
| // Boolean: automatically redirect to OAuth login when opening Roundcube without a valid session | ||
| $config['oauth_login_redirect'] = false; | ||
| ``` | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| auth_mechanisms = $auth_mechanisms oauthbearer xoauth2 | ||
|
|
||
| passdb { | ||
| driver = oauth2 | ||
| mechanisms = xoauth2 oauthbearer | ||
| args = /etc/dovecot/dovecot-oauth2.conf.ext | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| introspection_url = | ||
| # Dovecot defaults: | ||
| introspection_mode = auth | ||
| username_attribute = email |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| #!/bin/bash | ||
|
|
||
| function _setup_oauth2() { | ||
| _log 'debug' 'Setting up OAUTH2' | ||
|
|
||
| # Enable OAuth2 PassDB (Authentication): | ||
| sedfile -i -e '/\!include auth-oauth2\.conf\.ext/s/^#//' /etc/dovecot/conf.d/10-auth.conf | ||
| _replace_by_env_in_file 'OAUTH2_' '/etc/dovecot/dovecot-oauth2.conf.ext' | ||
|
|
||
| return 0 | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,56 @@ | ||
| # OAuth2 mock service | ||
| # | ||
| # Dovecot will query this service with the token it was provided. | ||
| # If the session for the token is valid, a response provides an attribute to perform a UserDB lookup on (default: email). | ||
|
|
||
| import json | ||
|
keval6b marked this conversation as resolved.
polarathene marked this conversation as resolved.
|
||
| import base64 | ||
| from http.server import BaseHTTPRequestHandler, HTTPServer | ||
|
|
||
| # OAuth2.0 Bearer token (paste into https://jwt.io/ to check it's contents). | ||
| # You should never need to edit this unless you REALLY need to change the issuer. | ||
| token = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJodHRwOi8vcHJvdmlkZXIuZXhhbXBsZS50ZXN0OjgwMDAvIiwic3ViIjoiODJjMWMzMzRkY2M2ZTMxMWFlNGFhZWJmZTk0NmM1ZTg1OGYwNTVhZmYxY2U1YTM3YWE3Y2M5MWFhYjE3ZTM1YyIsImF1ZCI6Im1haWxzZXJ2ZXIiLCJ1aWQiOiI4OU4zR0NuN1M1Y090WkZNRTVBeVhNbmxURFdVcnEzRmd4YWlyWWhFIn0.zuCytArbphhJn9XT_y9cBdGqDCNo68tBrtOwPIsuKNyF340SaOuZa0xarZofygytdDpLtYr56QlPTKImi-n1ZWrHkRZkwrQi5jQ-j_n2hEAL0vUToLbDnXYfc5q2w7z7X0aoCmiK8-fV7Kx4CVTM7riBgpElf6F3wNAIcX6R1ijUh6ISCL0XYsdogf8WUNZipXY-O4R7YHXdOENuOp3G48hWhxuUh9PsUqE5yxDwLsOVzCTqg9S5gxPQzF2eCN9J0I2XiIlLKvLQPIZ2Y_K7iYvVwjpNdgb4xhm9wuKoIVinYkF_6CwIzAawBWIDJAbix1IslkUPQMGbupTDtOgTiQ" | ||
|
|
||
| # This is the string the user-facing client (e.g. Roundcube) should send via IMAP to Dovecot. | ||
| # We include the user and the above token separated by '\1' chars as per the XOAUTH2 spec. | ||
| xoauth2 = base64.b64encode(f"[email protected]\1auth=Bearer {token}\1\1".encode("utf-8")) | ||
| # 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 | ||
| print("XOAUTH2 string: " + str(xoauth2)) | ||
|
Comment on lines
+10
to
+18
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Context (no action required)The token value is not the correct kind from what I understand?
I was a bit curious with why my initial attempt with Ory Hydra to use an ID Token was failing (without this mock server), so I'm under the impression that the Roundcube + Authentik setup may not have the behaviour this mock server is implying? When I complete an auth code grant flow with user login, Ory Hydra would respond with both access token and ID token. Roundcube docs indicate that if the response provides the extra data they need like username/email, that'll be used otherwise it'll use the access token to fetch that information IIRC.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. From Ory docs, ID Token (OIDC) vs Access Token (OAuth2): So Dovecot provides the Access Token to the authentication server that we have mocked here, and the JSON returned would be either of the above depending on endpoint AFAIK. You can see the common fields involved, but for the
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Reference:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this is due to a lack of documentation on Authentik's part (the service I've been testing with). I have been barely aware that it actually has an Hence I will concur, the access token appears to be more correct. |
||
|
|
||
|
|
||
| class HTTPRequestHandler(BaseHTTPRequestHandler): | ||
| def do_GET(self): | ||
| auth = self.headers.get("Authorization") | ||
| if auth is None: | ||
| self.send_response(401) | ||
| self.end_headers() | ||
| return | ||
| if len(auth.split()) != 2: | ||
| self.send_response(401) | ||
| self.end_headers() | ||
| return | ||
| auth = auth.split()[1] | ||
| # Valid session, respond with JSON containing the expected `email` claim to match as Dovecot username: | ||
| if auth == token: | ||
|
polarathene marked this conversation as resolved.
|
||
| self.send_response(200) | ||
| self.send_header('Content-Type', 'application/json') | ||
| self.end_headers() | ||
| self.wfile.write(json.dumps({ | ||
| "email": "[email protected]", | ||
| "email_verified": True, | ||
| "sub": "82c1c334dcc6e311ae4aaebfe946c5e858f055aff1ce5a37aa7cc91aab17e35c" | ||
| }).encode("utf-8")) | ||
| else: | ||
| self.send_response(401) | ||
| self.end_headers() | ||
|
|
||
| server = HTTPServer(('', 80), HTTPRequestHandler) | ||
| print("Starting server", flush=True) | ||
|
|
||
| try: | ||
| server.serve_forever() | ||
| except KeyboardInterrupt: | ||
| print() | ||
| print("Received keyboard interrupt") | ||
| finally: | ||
| print("Exiting") | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| a0 NOOP See test/config/oauth2/provider.py to generate the below XOAUTH2 string | ||
| a1 AUTHENTICATE XOAUTH2 dXNlcj11c2VyMUBsb2NhbGhvc3QubG9jYWxkb21haW4BYXV0aD1CZWFyZXIgZXlKaGJHY2lPaUpTVXpJMU5pSXNJblI1Y0NJNklrcFhWQ0o5LmV5SnBjM01pT2lKb2RIUndPaTh2Y0hKdmRtbGtaWEl1WlhoaGJYQnNaUzUwWlhOME9qZ3dNREF2SWl3aWMzVmlJam9pT0RKak1XTXpNelJrWTJNMlpUTXhNV0ZsTkdGaFpXSm1aVGswTm1NMVpUZzFPR1l3TlRWaFptWXhZMlUxWVRNM1lXRTNZMk01TVdGaFlqRTNaVE0xWXlJc0ltRjFaQ0k2SW0xaGFXeHpaWEoyWlhJaUxDSjFhV1FpT2lJNE9VNHpSME51TjFNMVkwOTBXa1pOUlRWQmVWaE5ibXhVUkZkVmNuRXpSbWQ0WVdseVdXaEZJbjAuenVDeXRBcmJwaGhKbjlYVF95OWNCZEdxRENObzY4dEJydE93UElzdUtOeUYzNDBTYU91WmEweGFyWm9meWd5dGREcEx0WXI1NlFsUFRLSW1pLW4xWldySGtSWmt3clFpNWpRLWpfbjJoRUFMMHZVVG9MYkRuWFlmYzVxMnc3ejdYMGFvQ21pSzgtZlY3S3g0Q1ZUTTdyaUJncEVsZjZGM3dOQUljWDZSMWlqVWg2SVNDTDBYWXNkb2dmOFdVTlppcFhZLU80UjdZSFhkT0VOdU9wM0c0OGhXaHh1VWg5UHNVcUU1eXhEd0xzT1Z6Q1RxZzlTNWd4UFF6RjJlQ045SjBJMlhpSWxMS3ZMUVBJWjJZX0s3aVl2VndqcE5kZ2I0eGhtOXd1S29JVmluWWtGXzZDd0l6QWF3QldJREpBYml4MUlzbGtVUFFNR2J1cFREdE9nVGlRAQE= | ||
| a2 EXAMINE INBOX | ||
| a3 LOGOUT |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,66 @@ | ||
| load "${REPOSITORY_ROOT}/test/helper/setup" | ||
| load "${REPOSITORY_ROOT}/test/helper/common" | ||
|
|
||
| BATS_TEST_NAME_PREFIX='[OAuth2] ' | ||
| CONTAINER1_NAME='dms-test_oauth2' | ||
| CONTAINER2_NAME='dms-test_oauth2_provider' | ||
|
|
||
| function setup_file() { | ||
| export DMS_TEST_NETWORK='test-network-oauth2' | ||
| export DMS_DOMAIN='example.test' | ||
| export FQDN_MAIL="mail.${DMS_DOMAIN}" | ||
| export FQDN_OAUTH2="oauth2.${DMS_DOMAIN}" | ||
|
|
||
| # Link the test containers to separate network: | ||
| # NOTE: If the network already exists, test will fail to start. | ||
| docker network create "${DMS_TEST_NETWORK}" | ||
|
|
||
| # Setup local oauth2 provider service: | ||
| docker run --rm -d --name "${CONTAINER2_NAME}" \ | ||
| --hostname "${FQDN_OAUTH2}" \ | ||
| --network "${DMS_TEST_NETWORK}" \ | ||
| --volume "${REPOSITORY_ROOT}/test/config/oauth2/:/app/" \ | ||
| docker.io/library/python:latest \ | ||
| python /app/provider.py | ||
|
|
||
| _run_until_success_or_timeout 20 sh -c "docker logs ${CONTAINER2_NAME} 2>&1 | grep 'Starting server'" | ||
|
|
||
| # | ||
| # Setup DMS container | ||
| # | ||
|
|
||
| # Add OAUTH2 configuration so that Dovecot can reach out to our mock provider (CONTAINER2) | ||
| local ENV_OAUTH2_CONFIG=( | ||
| --env ENABLE_OAUTH2=1 | ||
| --env OAUTH2_INTROSPECTION_URL=http://oauth2.example.test/userinfo/ | ||
| ) | ||
|
|
||
| export CONTAINER_NAME=${CONTAINER1_NAME} | ||
| local CUSTOM_SETUP_ARGUMENTS=( | ||
| "${ENV_OAUTH2_CONFIG[@]}" | ||
|
|
||
| --hostname "${FQDN_MAIL}" | ||
| --network "${DMS_TEST_NETWORK}" | ||
| ) | ||
|
|
||
| _init_with_defaults | ||
| _common_container_setup 'CUSTOM_SETUP_ARGUMENTS' | ||
| _wait_for_tcp_port_in_container 143 | ||
|
|
||
| # Set default implicit container fallback for helpers: | ||
| export CONTAINER_NAME=${CONTAINER1_NAME} | ||
| } | ||
|
|
||
| function teardown_file() { | ||
| docker rm -f "${CONTAINER1_NAME}" "${CONTAINER2_NAME}" | ||
| docker network rm "${DMS_TEST_NETWORK}" | ||
| } | ||
|
|
||
|
|
||
| @test "oauth2: imap connect and authentication works" { | ||
| # An initial connection needs to be made first, otherwise the auth attempt fails | ||
| _run_in_container_bash 'nc -vz 0.0.0.0 143' | ||
|
|
||
| _nc_wrapper 'auth/imap-oauth2-auth.txt' '-w 1 0.0.0.0 143' | ||
| assert_output --partial 'Examine completed' | ||
| } |


Uh oh!
There was an error while loading. Please reload this page.