From f3a7f08f9615de40df800c6179e7563c1a394910 Mon Sep 17 00:00:00 2001 From: Brennan Kinney <5098581+polarathene@users.noreply.github.com> Date: Sat, 20 Jan 2024 22:49:09 +1300 Subject: [PATCH] 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 --- CHANGELOG.md | 1 + test/config/oauth2/Caddyfile | 88 +++++++++++++++++++++ test/config/oauth2/compose.yaml | 15 ++++ test/config/oauth2/provider.py | 56 ------------- test/files/auth/imap-oauth2-auth.txt | 4 - test/files/auth/imap-oauth2-oauthbearer.txt | 4 + test/files/auth/imap-oauth2-xoauth2.txt | 4 + test/tests/serial/mail_with_oauth2.bats | 40 ++++++---- 8 files changed, 138 insertions(+), 74 deletions(-) create mode 100644 test/config/oauth2/Caddyfile create mode 100644 test/config/oauth2/compose.yaml delete mode 100644 test/config/oauth2/provider.py delete mode 100644 test/files/auth/imap-oauth2-auth.txt create mode 100644 test/files/auth/imap-oauth2-oauthbearer.txt create mode 100644 test/files/auth/imap-oauth2-xoauth2.txt diff --git a/CHANGELOG.md b/CHANGELOG.md index b6126956..90a905f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)): diff --git a/test/config/oauth2/Caddyfile b/test/config/oauth2/Caddyfile new file mode 100644 index 00000000..e116aa55 --- /dev/null +++ b/test/config/oauth2/Caddyfile @@ -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 <&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=, method=${AUTH_METHOD}" }