feat: Auth - OAuth2 (Dovecot PassDB) (#3480)

Co-authored-by: Brennan Kinney <5098581+polarathene@users.noreply.github.com>
This commit is contained in:
Keval Kapdee 2024-01-12 20:45:14 +00:00 committed by GitHub
parent 06fab3f129
commit 52c4582f7b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 279 additions and 3 deletions

View file

@ -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
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"user=user1@localhost.localdomain\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))
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:
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.end_headers()
self.wfile.write(json.dumps({
"email": "user1@localhost.localdomain",
"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")

View file

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

View file

@ -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'
}