tests: Revise OAuth2 tests (#3795)
* tests: OAuth2 - Replace Python `/userinfo` endpoint with Caddy Better documented, easier flow and separation of concerns via Caddy. The python code had additional noise related to setting up a basic API which is abstracted away via `Caddyfile` config that's dedicated to this task. * tests: OAuth2 - Minimize noise + Improve test assertion Caddyfile can use an Access Token instead of a JWT. Much smaller and correct for this OAuth2 configuration. This new value has been documented inline. Likewise the `sub` field returned is not important to this test. `email_verified` is kept as it may be helpful for further coverage testing. The actual test-case has better assertions for success and failure by checking for Dovecot logs we expect instead of netcat response. `oauth2` to `auth` for the Caddy container hostname is not necessary, just a more generic subdomain choice. * tests: OAuth2 - Caddyfile `imap/xoauth2` route dynamic via query string This way is more flexible and doesn't require modifying the `Caddyfile` directly, while still easy to use. Additionally simplifies understanding the Caddyfile to maintainers by removing the `route` directive that was required to ensure a deterministic order of vars. * tests: OAuth2 - `/imap/xoauth2` respond with IMAP commands for netcat Since this is the only intended usage, might as well have it respond with the full file content. * tests: OAuth2 - Implement coverage for `OAUTHBEARER` Caddyfile route for `/imap/` now accepts any subpath to support handling both `xoauth2` and `oauthbearer` subpaths. Both SASL mechanisms represent the same information, with `XOAUTH2` being a common mechanism to encounter defined by Google, whilst `OAUTHBEARER` is the newer variant standardized by RFC 7628 but not yet as widely adopted. The request to `/userinfo` endpoint will be the same, only the `credentials` value to be encoded differs. Instead of repeating the block for a similar route, this difference is handled via the Caddyfile `map` directive. We match the path context (_`/xoauth2` or `/oauthbearer`, the `/imap` prefix was stripped by `handle_path` earlier_), when there is a valid match, `sasl_mechanism` and `credentials` map vars are created and assigned to be referenced by the later `respond` directive. --- Repeat the same test-case logic, DRY with log asserts extracted to a common function call. This should be fine as the auth method will be sufficient to match against or a common failure caught. * tests: OAuth2 - Minor revisions Separate test cases and additional comment on creating the same base64 encoded credentials via CLI as an alternative to running Caddy. Added a simple `compose.yaml` for troubleshooting or running the container for the `/imap/xoauth2` / `/imap/oauthbearer` endpoints. * tests: OAuth2 - Route endpoints in Caddyfile with snippets instead `reverse_proxy` was a bit more convenient, but the additional internal ports weren't really relevant. It also added noise to logging when troubleshooting. The `import` directive with Snippet blocks instead is a bit cleaner, but when used in a single file snippets must be defined prior to referencing them with the `import` directive. --- `compose.yaml` inlines the examples, with slight modification to `localhost:80`, since the Caddyfile examples `auth.example.test` is more relevant to the tests which can use it, and not applicable to troubleshooting locally outside of tests. * chore: Add entry to `CHANGELOG.md` * chore: Additional context on access token
This commit is contained in:
parent
a5d536201b
commit
f3a7f08f96
|
@ -25,6 +25,7 @@ All notable changes to this project will be documented in this file. The format
|
|||
### Updates
|
||||
|
||||
- **Tests**:
|
||||
- Revised OAuth2 test ([#3795](https://github.com/docker-mailserver/docker-mailserver/pull/3795))
|
||||
- Replace `wc -l` with `grep -c` ([#3752](https://github.com/docker-mailserver/docker-mailserver/pull/3752))
|
||||
- Revised testing of service process management (supervisord) to be more robust ([#3780](https://github.com/docker-mailserver/docker-mailserver/pull/3780))
|
||||
- Refactored mail sending ([#3747](https://github.com/docker-mailserver/docker-mailserver/pull/3747) & [#3772](https://github.com/docker-mailserver/docker-mailserver/pull/3772)):
|
||||
|
|
|
@ -0,0 +1,88 @@
|
|||
# Mocked OAuth2 /userinfo endpoint normally provided via an Authorization Server (AS) / Identity Provider (IdP)
|
||||
#
|
||||
# Dovecot will query the mocked `/userinfo` endpoint with the OAuth2 bearer token it was provided during login.
|
||||
# If the session for the token is valid, a response returns an attribute to perform a UserDB lookup on (default: email).
|
||||
|
||||
# `DMS_YWNjZXNzX3Rva2Vu` is the access token our OAuth2 tests expect for an authorization request to be successful.
|
||||
# - The token was created by base64 encoding the string `access_token`, followed by adding `DMS_` as a prefix.
|
||||
# - Normally an access token is a short-lived value associated to a login session. The value does not encode any real data.
|
||||
# It is an opaque token: https://oauth.net/2/bearer-tokens/
|
||||
|
||||
# NOTE: The main server config is at the end within the `:80 { ... }` block.
|
||||
# This is because the endpoints are extracted out into Caddy snippets, which must be defined before they're referenced.
|
||||
|
||||
# /userinfo
|
||||
(route-userinfo) {
|
||||
vars token "DMS_YWNjZXNzX3Rva2Vu"
|
||||
|
||||
# Expects to match an authorization header with a specific bearer token:
|
||||
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Authentication#authentication_schemes
|
||||
@auth header Authorization "Bearer {vars.token}"
|
||||
|
||||
# If the provided authorization header has the expected value (bearer token), respond with this JSON payload:
|
||||
handle @auth {
|
||||
# JSON inlined via HereDoc string feature:
|
||||
# Dovecot OAuth2 defaults to `username_attribute = email`, which must be returned in the response to match
|
||||
# with the `user` credentials field that Dovecot received via base64 encoded IMAP `AUTHENTICATE` value.
|
||||
respond <<EOF
|
||||
{
|
||||
"email": "user1@localhost.localdomain",
|
||||
"email_verified": true
|
||||
}
|
||||
EOF
|
||||
}
|
||||
|
||||
# Failed to authorize, close connection and send a 401 status (unauthorized):
|
||||
respond 401 {
|
||||
close
|
||||
}
|
||||
}
|
||||
|
||||
# /imap/xoauth2
|
||||
# Generate IMAP commands for authentication testing
|
||||
# Base64 encoded credentials can alternative be done via CLI with:
|
||||
# echo -en 'user=${USERNAME}\001auth=Bearer ${ACCESS_TOKEN}\001\001' | base64 -w0; echo
|
||||
#
|
||||
# Provide `user` and `access_token` values via query string parameters:
|
||||
# curl 'http://auth.example.test/imap/xoauth2?user=user1@localhost.localdomain&access_token=DMS_YWNjZXNzX3Rva2Vu'
|
||||
# curl 'http://auth.example.test/imap/oauthbearer?user=user1@localhost.localdomain&access_token=DMS_YWNjZXNzX3Rva2Vu'
|
||||
#
|
||||
# Example Response:
|
||||
# a0 AUTHENTICATE XOAUTH2 dXNlcj11c2VyMUBsb2NhbGhvc3QubG9jYWxkb21haW4BYXV0aD1CZWFyZXIgRE1TX1lXTmpaWE56WDNSdmEyVnUBAQ==
|
||||
# a1 EXAMINE INBOX
|
||||
# a2 LOGOUT
|
||||
#
|
||||
# When Dovecot queries /userinfo endpoint, it will be after base64 decoding the IMAP `AUTHENTICATE` value,
|
||||
# and sending the `auth` value from the `credentials` variable as an HTTP Authorization header.
|
||||
(route-imap) {
|
||||
# The login username + OAuth2 access token prior to Base64 encoding, as per the XOAUTH2 spec:
|
||||
# https://developers.google.com/gmail/imap/xoauth2-protocol#the_sasl_xoauth2_mechanism
|
||||
# For OAUTHBEARER `host` and `port` do not appear to affect authentication with Dovecot
|
||||
map {path} {sasl_mechanism} {credentials} {
|
||||
/xoauth2 XOAUTH2 "user={query.user}\001auth=Bearer {query.access_token}\001\001"
|
||||
/oauthbearer OAUTHBEARER "n,a={query.user},\001host=localhost\001port=143\001auth=Bearer {query.access_token}\001\001"
|
||||
}
|
||||
|
||||
# Responds with the raw IMAP commands for testing XOAUTH2 authentication.
|
||||
# Uses the `b64enc` template function to encode credentials as required for `IMAP AUTHENTICATE`:
|
||||
templates
|
||||
respond <<EOF
|
||||
a0 AUTHENTICATE {sasl_mechanism} {{b64enc "{credentials}"}}
|
||||
a1 EXAMINE INBOX
|
||||
a2 LOGOUT
|
||||
EOF
|
||||
}
|
||||
|
||||
# Routes the endpoints to the logical blocks extracted out as snippets above
|
||||
:80 {
|
||||
# This is the `/userinfo` endpoint that Dovecot connects to with the OAuth2 setting (default: `introspection_mode = auth`).
|
||||
# Example: curl http://auth.example.test/userinfo -H 'Authorization: Bearer DMS_YWNjZXNzX3Rva2Vu'
|
||||
handle_path /userinfo {
|
||||
import route-userinfo
|
||||
}
|
||||
|
||||
# An additional endpoint for maintainers to generate `test/files/auth/imap-oauth2-auth.txt`
|
||||
handle_path /imap/* {
|
||||
import route-imap
|
||||
}
|
||||
}
|
|
@ -0,0 +1,15 @@
|
|||
# Provides support for running this container outside of tests
|
||||
# Run this with `docker compose up`
|
||||
services:
|
||||
caddy-oauth2:
|
||||
image: caddy:2.7
|
||||
container_name: dms-oauth2
|
||||
ports:
|
||||
- "80:80"
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
|
||||
# Examples:
|
||||
# curl http://localhost:80/userinfo -H 'Authorization: Bearer DMS_YWNjZXNzX3Rva2Vu'
|
||||
# curl 'http://localhost:80/imap/xoauth2?user=user1@localhost.localdomain&access_token=DMS_YWNjZXNzX3Rva2Vu'
|
||||
# curl 'http://localhost:80/imap/oauthbearer?user=user1@localhost.localdomain&access_token=DMS_YWNjZXNzX3Rva2Vu'
|
|
@ -1,56 +0,0 @@
|
|||
# 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")
|
|
@ -1,4 +0,0 @@
|
|||
a0 NOOP See test/config/oauth2/provider.py to generate the below XOAUTH2 string
|
||||
a1 AUTHENTICATE XOAUTH2 dXNlcj11c2VyMUBsb2NhbGhvc3QubG9jYWxkb21haW4BYXV0aD1CZWFyZXIgZXlKaGJHY2lPaUpTVXpJMU5pSXNJblI1Y0NJNklrcFhWQ0o5LmV5SnBjM01pT2lKb2RIUndPaTh2Y0hKdmRtbGtaWEl1WlhoaGJYQnNaUzUwWlhOME9qZ3dNREF2SWl3aWMzVmlJam9pT0RKak1XTXpNelJrWTJNMlpUTXhNV0ZsTkdGaFpXSm1aVGswTm1NMVpUZzFPR1l3TlRWaFptWXhZMlUxWVRNM1lXRTNZMk01TVdGaFlqRTNaVE0xWXlJc0ltRjFaQ0k2SW0xaGFXeHpaWEoyWlhJaUxDSjFhV1FpT2lJNE9VNHpSME51TjFNMVkwOTBXa1pOUlRWQmVWaE5ibXhVUkZkVmNuRXpSbWQ0WVdseVdXaEZJbjAuenVDeXRBcmJwaGhKbjlYVF95OWNCZEdxRENObzY4dEJydE93UElzdUtOeUYzNDBTYU91WmEweGFyWm9meWd5dGREcEx0WXI1NlFsUFRLSW1pLW4xWldySGtSWmt3clFpNWpRLWpfbjJoRUFMMHZVVG9MYkRuWFlmYzVxMnc3ejdYMGFvQ21pSzgtZlY3S3g0Q1ZUTTdyaUJncEVsZjZGM3dOQUljWDZSMWlqVWg2SVNDTDBYWXNkb2dmOFdVTlppcFhZLU80UjdZSFhkT0VOdU9wM0c0OGhXaHh1VWg5UHNVcUU1eXhEd0xzT1Z6Q1RxZzlTNWd4UFF6RjJlQ045SjBJMlhpSWxMS3ZMUVBJWjJZX0s3aVl2VndqcE5kZ2I0eGhtOXd1S29JVmluWWtGXzZDd0l6QWF3QldJREpBYml4MUlzbGtVUFFNR2J1cFREdE9nVGlRAQE=
|
||||
a2 EXAMINE INBOX
|
||||
a3 LOGOUT
|
|
@ -0,0 +1,4 @@
|
|||
a0 NOOP See test/config/oauth2/Caddyfile to generate the below OAUTHBEARER string
|
||||
a1 AUTHENTICATE OAUTHBEARER bixhPXVzZXIxQGxvY2FsaG9zdC5sb2NhbGRvbWFpbiwBaG9zdD1sb2NhbGhvc3QBcG9ydD0xNDMBYXV0aD1CZWFyZXIgRE1TX1lXTmpaWE56WDNSdmEyVnUBAQ==
|
||||
a2 EXAMINE INBOX
|
||||
a3 LOGOUT
|
|
@ -0,0 +1,4 @@
|
|||
a0 NOOP See test/config/oauth2/Caddyfile to generate the below XOAUTH2 string
|
||||
a1 AUTHENTICATE XOAUTH2 dXNlcj11c2VyMUBsb2NhbGhvc3QubG9jYWxkb21haW4BYXV0aD1CZWFyZXIgRE1TX1lXTmpaWE56WDNSdmEyVnUBAQ==
|
||||
a2 EXAMINE INBOX
|
||||
a3 LOGOUT
|
|
@ -9,7 +9,7 @@ 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}"
|
||||
export FQDN_OAUTH2="auth.${DMS_DOMAIN}"
|
||||
|
||||
# Link the test containers to separate network:
|
||||
# NOTE: If the network already exists, test will fail to start.
|
||||
|
@ -19,20 +19,19 @@ function setup_file() {
|
|||
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
|
||||
--volume "${REPOSITORY_ROOT}/test/config/oauth2/Caddyfile:/etc/caddy/Caddyfile:ro" \
|
||||
caddy:2.7
|
||||
|
||||
_run_until_success_or_timeout 20 sh -c "docker logs ${CONTAINER2_NAME} 2>&1 | grep 'Starting server'"
|
||||
_run_until_success_or_timeout 20 bash -c "docker logs ${CONTAINER2_NAME} 2>&1 | grep 'serving initial configuration'"
|
||||
|
||||
#
|
||||
# Setup DMS container
|
||||
#
|
||||
|
||||
# Add OAUTH2 configuration so that Dovecot can reach out to our mock provider (CONTAINER2)
|
||||
# Add OAuth2 configuration so that Dovecot can query our mocked identity provider (CONTAINER2)
|
||||
local ENV_OAUTH2_CONFIG=(
|
||||
--env ENABLE_OAUTH2=1
|
||||
--env OAUTH2_INTROSPECTION_URL=http://oauth2.example.test/userinfo/
|
||||
--env OAUTH2_INTROSPECTION_URL=http://auth.example.test/userinfo
|
||||
)
|
||||
|
||||
export CONTAINER_NAME=${CONTAINER1_NAME}
|
||||
|
@ -49,6 +48,9 @@ function setup_file() {
|
|||
|
||||
# Set default implicit container fallback for helpers:
|
||||
export CONTAINER_NAME=${CONTAINER1_NAME}
|
||||
|
||||
# An initial connection needs to be made first, otherwise the auth attempts fail
|
||||
_run_in_container_bash 'nc -vz 0.0.0.0 143'
|
||||
}
|
||||
|
||||
function teardown_file() {
|
||||
|
@ -56,11 +58,21 @@ function teardown_file() {
|
|||
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'
|
||||
@test "should authenticate with XOAUTH2 over IMAP" {
|
||||
_nc_wrapper 'auth/imap-oauth2-xoauth2.txt' '-w 1 0.0.0.0 143'
|
||||
__verify_successful_login 'XOAUTH2'
|
||||
}
|
||||
|
||||
@test "should authenticate with OAUTHBEARER over IMAP" {
|
||||
_nc_wrapper 'auth/imap-oauth2-oauthbearer.txt' '-w 1 0.0.0.0 143'
|
||||
__verify_successful_login 'OAUTHBEARER'
|
||||
}
|
||||
|
||||
function __verify_successful_login() {
|
||||
local AUTH_METHOD=${1}
|
||||
|
||||
# Inspect the relevant Dovecot logs to catch failure / success:
|
||||
_run_in_container grep 'dovecot:' /var/log/mail.log
|
||||
refute_output --partial 'oauth2 failed: Introspection failed'
|
||||
assert_output --partial "dovecot: imap-login: Login: user=<user1@localhost.localdomain>, method=${AUTH_METHOD}"
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue