diff --git a/docs/modules/servers/partials/configure/sieve.adoc b/docs/modules/servers/partials/configure/sieve.adoc index 7ecd4c452f7..25f2219aff4 100644 --- a/docs/modules/servers/partials/configure/sieve.adoc +++ b/docs/modules/servers/partials/configure/sieve.adoc @@ -86,4 +86,28 @@ Optional integer, defaults to 2 times the count of CPUs. | maxExecutorCount | Set the maximum count of worker threads. Worker threads takes care of potentially blocking tasks like executing ManageSieve commands. Optional integer, defaults to 16. -|=== \ No newline at end of file + +| oidc +| If this property is present, OIDC will be configured and the following properties are mandatory (unless otherwise specified). + +| oidc.oidcConfigurationURL +| Your identity provider's OIDC discovery URL. This is currently not used for managesieve but is still required when OIDC is configured. + +| oidc.jwksURL +| URL to the endpoint for the JSON Web Key Set of your provider. This is used to locally validate tokens. + +| oidc.claim +| Name of the claim in the token you want to use as the identifier for the user (e.g. "email_address"). + +| oidc.scope +| OIDC scope. This is currently not used for managesieve but is still required when OIDC is configured. + +| oidc.introspection.url +| URL to your identity provider's introspection endpoint. It is optional and if specified James will use the endpoint to validate the token in addition to local validation. + +| oidc.introspection.auth +| Provide Authorization header for introspection requests (optional, e.g. `Basic xyz`). + +| oidc.userinfo.url +| URL to your identity provider's userinfo endpoint. It is optional and if specified James will use the endpoint to validate the token in addition to local validation. James will ignore this option if `oidc.introspection.url` is already configured. +|=== diff --git a/examples/oidc/README.md b/examples/oidc/README.md index ea9b617863f..8fe5e13e920 100644 --- a/examples/oidc/README.md +++ b/examples/oidc/README.md @@ -6,12 +6,10 @@ This is example of an OIDC setup with James. The API Gateway for example is [Apisix](https://apisix.apache.org/), we can use Apisix for websocket gateway, horizontal scaling, etc... -This [docker-compose](docker-compose.yml) will start the following services: +This [docker compose](./compose.yaml) will start the following services: - apisix: The image `linagora/apisix:3.2.0-debian-javaplugin` was created by Linagora. It based on `apisix:3.2.0-debian`, it already contain apisix plugin for SLO (Single Logout) and rewrite the `X-User` header. - - Dockerfile: [here](https://github.com/linagora/tmail-backend/blob/master/demo/apisix/Dockerfile) - - Project `tmail-apisix-plugin-runner`: [here](https://github.com/linagora/tmail-backend/tree/master/demo/apisix/tmail-apisix-plugin-runner) - Apisix being the OIDC gateway against James by exposing two endpoints: - `POST /jmap` for JMAP requests against James with normal authentication - `POST /oidc/jmap` for JMAP request against James with a JWT token issued by the LemonLDAP @@ -161,21 +159,21 @@ Use websocket with endpoint `ws://apisix.example.com:9080/oidc/jmap/ws` and the We would use Thunderbird version 91.4.1 as a mail client (above versions should work). * Open `/thunderbird/omni.ja` in your host, find and modify `OAuth2Providers.jsm`: - * Add James hostname in kHostnames: `["localhost", ["james.local", "email"]],` + * Add James hostname in kHostnames: `["localhost", ["james.example.com", "email"]],` * Register using `james-thunderbird` Keycloak client in kIssuers: ``` [ - "james.local", + "james.example.com", [ "james-thunderbird", //client_id from keycloak "Xw9ht1veTu0Tk5sMMy03PdzY3AiFvssw", // client_secret from keycloak - "http://keycloak.local:8080/auth/realms/oidc/protocol/openid-connect/auth", - "http://keycloak.local:8080/auth/realms/oidc/protocol/openid-connect/token", + "http://sso.example.com:8080/auth/realms/oidc/protocol/openid-connect/auth", + "http://sso.example.com:8080/auth/realms/oidc/protocol/openid-connect/token", ], ] ``` -* Adding a line `127.0.0.1 keycloak.local` to your `/etc/hosts` so Thunderbird can resolve the address of keycloak. +* Adding a line `127.0.0.1 sso.example.com` to your `/etc/hosts` so Thunderbird can resolve the address of keycloak. * Run Thunderbird, configure it using `james-user@localhost` account against these IMAP/SMTP settings: * IMAP: server: localhost, port: 143, connection security: No, authentication method: OAUTH2 ![](_media/imap-setting.png) @@ -189,4 +187,35 @@ We would use Thunderbird version 91.4.1 as a mail client (above versions should ![](_media/receive-mail.png) A remark here is that if you generate a new client_secret for `james-thunderbird` client in Keycloak, you have to modify -it accordingly in `OAuth2Providers.jsm`. \ No newline at end of file +it accordingly in `OAuth2Providers.jsm`. + +### IMAP on the CLI + +You can test logging into IMAP on the CLI by connecting with `telnet localhost 143`. Here are some commands that can be tried: + +- `a AUTHENTICATE XOAUTH2 ` (unauthenticated state) +- `b AUTHENTICATE OAUTHBEARER ` (unauthenticated state) +- `c LOGOUT` (any state) + +You can get the initial response from the [test script](./test.sh). + +### ManageSieve on the CLI + +You can test logging into IMAP on the CLI by connecting with `telnet localhost 4190`. Here are some commands that can be tried: + +- `AUTHENTICATE "XOAUTH2" ""` (unauthenticated state) +- `AUTHENTICATE "OAUTHBEARER" ""` (unauthenticated state) +- `CAPABILITY` (any state) +- `LOGOUT` (any state) + +You can get the initial response from the [test script](./test.sh). + +### SMTP on the CLI + +You can test logging into IMAP on the CLI by connecting with `telnet localhost 587`. Here are some commands that can be tried: + +- `AUTH XOAUTH2 ` (unauthenticated state) +- `AUTH OAUTHBEARER ` (unauthenticated state) +- `QUIT` (any state) + +You can get the initial response from the [test script](./test.sh). diff --git a/examples/oidc/apisix/conf/apisix.yaml b/examples/oidc/apisix/conf/apisix.yaml index cc8d14db342..cf27c1afe29 100644 --- a/examples/oidc/apisix/conf/apisix.yaml +++ b/examples/oidc/apisix/conf/apisix.yaml @@ -219,7 +219,7 @@ upstreams: - id: jmap_upstream nodes: - "james:80": 1 + "james.example.com:80": 1 type: roundrobin plugin_configs: diff --git a/examples/oidc/docker-compose.yml b/examples/oidc/compose.yaml similarity index 62% rename from examples/oidc/docker-compose.yml rename to examples/oidc/compose.yaml index a27f1ff294b..dc39fd20bac 100644 --- a/examples/oidc/docker-compose.yml +++ b/examples/oidc/compose.yaml @@ -1,5 +1,3 @@ -version: "3" - services: apisix: container_name: apisix.example.com @@ -8,11 +6,11 @@ services: - ./apisix/conf/apisix.yaml:/usr/local/apisix/conf/apisix.yaml - ./apisix/conf/config.yaml:/usr/local/apisix/conf/config.yaml environment: - - X_USER_SECRET=xusersecret123 + X_USER_SECRET: xusersecret123 networks: - james ports: - - "9080:9080/tcp" + - "127.0.0.1:9080:9080" james: depends_on: @@ -20,19 +18,24 @@ services: networks: - james image: apache/james:memory-latest - container_name: james - hostname: james.local - command: - - --generate-keystore + container_name: james.example.com + hostname: james.example.com + command: [--generate-keystore] volumes: - ./james/usersrepository.xml:/root/conf/usersrepository.xml - ./james/jmap.properties:/root/conf/jmap.properties + - ./james/imapserver.xml:/root/conf/imapserver.xml + - ./james/smtpserver.xml:/root/conf/smtpserver.xml + - ./james/managesieveserver.xml:/root/conf/managesieveserver.xml ports: - - "8000:8000" + - "127.0.0.1:8000:8000" + - "127.0.0.1:143:143" + - "127.0.0.1:587:587" + - "127.0.0.1:4190:4190" healthcheck: test: ["CMD", "curl", "-f", "http://james:8000/domains"] - sso.example.com: + sso: depends_on: - ldap image: quay.io/keycloak/keycloak:16.1.0 @@ -40,27 +43,25 @@ services: volumes: - ./keycloak/realm-oidc.json:/tmp/realm-oidc.json ports: - - "8080:8080" + - "127.0.0.1:8080:8080" environment: - - KEYCLOAK_USER=admin - - KEYCLOAK_PASSWORD=admin - - KEYCLOAK_IMPORT=/tmp/realm-oidc.json + KEYCLOAK_USER: admin + KEYCLOAK_PASSWORD: admin + KEYCLOAK_IMPORT: /tmp/realm-oidc.json networks: - james: - aliases: - - keycloak + - james ldap: - container_name: ldap + container_name: ldap.example.com image: osixia/openldap:1.5.0 ports: - - "389:389" - - "636:636" + - "127.0.0.1:389:389" + - "127.0.0.1:636:636" command: [--copy-service] volumes: - ./ldap/populate.ldif:/container/service/slapd/assets/config/bootstrap/ldif/data.ldif environment: - - LDAP_DOMAIN=localhost + LDAP_DOMAIN: localhost networks: - james @@ -71,7 +72,7 @@ services: networks: - james ports: - - "6379:6379" + - "127.0.0.1:6379:6379" networks: - james: \ No newline at end of file + james: diff --git a/examples/oidc/james/imapserver.xml b/examples/oidc/james/imapserver.xml index e590c7dd5e1..641f6c50675 100644 --- a/examples/oidc/james/imapserver.xml +++ b/examples/oidc/james/imapserver.xml @@ -4,12 +4,6 @@ imapserver 0.0.0.0:143 200 - - file://conf/keystore - PKCS12 - james72laBalle - org.bouncycastle.jce.provider.BouncyCastleProvider - 0 0 120 @@ -18,12 +12,12 @@ true - http://keycloak:8080/auth/realms/oidc/.well-known/openid-configuration - http://keycloak:8080/auth/realms/oidc/protocol/openid-connect/certs + http://sso.example.com:8080/auth/realms/oidc/.well-known/openid-configuration + http://sso.example.com:8080/auth/realms/oidc/protocol/openid-connect/certs email openid profile email - http://keycloak:8080/auth/realms/oidc/protocol/openid-connect/token/introspect + http://sso.example.com:8080/auth/realms/oidc/protocol/openid-connect/token/introspect Basic amFtZXMtdGh1bmRlcmJpcmQ6WHc5aHQxdmVUdTBUazVzTU15MDNQZHpZM0FpRnZzc3c= diff --git a/examples/oidc/james/managesieveserver.xml b/examples/oidc/james/managesieveserver.xml new file mode 100644 index 00000000000..a0e2b79439b --- /dev/null +++ b/examples/oidc/james/managesieveserver.xml @@ -0,0 +1,21 @@ + + + + managesieveserver + 0.0.0.0:4190 + 200 + 360 + 0 + 0 + + http://sso.example.com:8080/auth/realms/oidc/.well-known/openid-configuration + http://sso.example.com:8080/auth/realms/oidc/protocol/openid-connect/certs + email + openid profile email + + http://sso.example.com:8080/auth/realms/oidc/protocol/openid-connect/token/introspect + Basic amFtZXMtdGh1bmRlcmJpcmQ6WHc5aHQxdmVUdTBUazVzTU15MDNQZHpZM0FpRnZzc3c= + + + + diff --git a/examples/oidc/james/smtpserver.xml b/examples/oidc/james/smtpserver.xml index 6af07c4554b..e4f3655157b 100644 --- a/examples/oidc/james/smtpserver.xml +++ b/examples/oidc/james/smtpserver.xml @@ -4,13 +4,6 @@ smtpserver 0.0.0.0:587 200 - - file://conf/keystore - PKCS12 - james72laBalle - org.bouncycastle.jce.provider.BouncyCastleProvider - SunX509 - 360 0 0 @@ -18,12 +11,12 @@ forUnauthorizedAddresses true - http://keycloak:8080/auth/realms/oidc/.well-known/openid-configuration - http://keycloak:8080/auth/realms/oidc/protocol/openid-connect/certs + http://sso.example.com:8080/auth/realms/oidc/.well-known/openid-configuration + http://sso.example.com:8080/auth/realms/oidc/protocol/openid-connect/certs email openid profile email - http://keycloak:8080/auth/realms/oidc/protocol/openid-connect/token/introspect + http://sso.example.com:8080/auth/realms/oidc/protocol/openid-connect/token/introspect Basic amFtZXMtdGh1bmRlcmJpcmQ6WHc5aHQxdmVUdTBUazVzTU15MDNQZHpZM0FpRnZzc3c= @@ -39,5 +32,3 @@ - - diff --git a/examples/oidc/james/usersrepository.xml b/examples/oidc/james/usersrepository.xml index a0c316db385..3f348fd41c9 100644 --- a/examples/oidc/james/usersrepository.xml +++ b/examples/oidc/james/usersrepository.xml @@ -22,7 +22,7 @@ /dev/null` ACCESS_TOKEN=`echo $GET_TOKEN_RESPONSE 2>/dev/null |perl -pe 's/^.*"access_token"\s*:\s*"(.*?)".*$/$1/'` +echo "Access token: $ACCESS_TOKEN" REFRESH_TOKEN=`echo $GET_TOKEN_RESPONSE 2>/dev/null |perl -pe 's/^.*"refresh_token"\s*:\s*"(.*?)".*$/$1/'` +echo "Refresh token: $REFRESH_TOKEN" echo "Got an access_token" if curl -H "Authorization: Bearer $ACCESS_TOKEN" http://sso.example.com:8080/auth/realms/oidc/protocol/openid-connect/userinfo 2>/dev/null| grep james-user >/dev/null; then @@ -23,8 +27,7 @@ else echo "ACCESS_TOKEN VERIFICATION FAILED" fi -echo -n "Trying James: " - +echo -n "Trying James:" APISIX_JMAP_ENDPOINT=apisix.example.com:9080/oidc/jmap/session if curl -v -H 'Accept: application/json; jmapVersion=rfc-8621' -H "Authorization: Bearer $ACCESS_TOKEN" $APISIX_JMAP_ENDPOINT 2>/dev/null | grep uploadUrl >/dev/null; then echo "OK" @@ -32,6 +35,47 @@ else echo "Not OK" fi +XOAUTH2_INITIAL_CLIENT_RESPONSE=`echo -n -e "user=james-user@localhost\x01auth=Bearer ${ACCESS_TOKEN}\x01\x01" | base64 -w 0` +echo "XOAUTH2: $XOAUTH2_INITIAL_CLIENT_RESPONSE" +OAUTHBEARER_INITIAL_CLIENT_RESPONSE=`echo -n -e "n,a=james-user@localhost\x01auth=Bearer ${ACCESS_TOKEN}\x01\x01" | base64 -w 0` +echo "OAUTHBEARER: $OAUTHBEARER_INITIAL_CLIENT_RESPONSE" + +MANAGESIEVE_XOAUTH2_RESPONSE=`(echo "AUTHENTICATE \"XOAUTH2\" \"${XOAUTH2_INITIAL_CLIENT_RESPONSE}\""; echo "CAPABILITY"; echo "LOGOUT"; sleep 3) | telnet 127.0.0.1 4190` +if echo "$MANAGESIEVE_XOAUTH2_RESPONSE" | grep "\"OWNER\" \"james-user@localhost\"" > /dev/null; then + echo "Success: Managesieve XOAUTH2 login" +else + echo "Error: Managesieve XOAUTH2 login" +fi +if echo "$MANAGESIEVE_XOAUTH2_RESPONSE" | grep "OK channel is closing" > /dev/null; then + echo "Success: Managesieve XOAUTH2 logout" +else + echo "Error: Managesieve XOAUTH2 logout" +fi + +IMAP_XOAUTH2_RESPONSE=`(echo "a AUTHENTICATE XOAUTH2 ${XOAUTH2_INITIAL_CLIENT_RESPONSE}"; echo "c LOGOUT"; sleep 3) | telnet 127.0.0.1 143` +if echo "$IMAP_XOAUTH2_RESPONSE" | grep "a OK AUTHENTICATE completed" > /dev/null; then + echo "Success: IMAP XOAUTH2 login" +else + echo "Error: IMAP XOAUTH2 login" +fi +if echo "$IMAP_XOAUTH2_RESPONSE" | grep "c OK LOGOUT completed" > /dev/null; then + echo "Success: IMAP XOAUTH2 logout" +else + echo "Error: IMAP XOAUTH2 logout" +fi + +SMTP_XOAUTH2_RESPONSE=`(echo "AUTH XOAUTH2 ${XOAUTH2_INITIAL_CLIENT_RESPONSE}"; echo "QUIT"; sleep 3) | telnet 127.0.0.1 587` +if echo "$SMTP_XOAUTH2_RESPONSE" | grep "235 Authentication successful" > /dev/null; then + echo "Success: SMTP XOAUTH2 login" +else + echo "Error: SMTP XOAUTH2 login" +fi +if echo "$SMTP_XOAUTH2_RESPONSE" | grep "221 2.0.0 james.example.com Service closing transmission channel" > /dev/null; then + echo "Success: SMTP XOAUTH2 logout" +else + echo "Error: SMTP XOAUTH2 logout" +fi + # Logout curl --location 'http://sso.example.com:8080/auth/realms/oidc/protocol/openid-connect/logout' \ diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/authenticate.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/authenticate.test index 334699cf14a..1b03d55e313 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/authenticate.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/authenticate.test @@ -18,20 +18,27 @@ ################################################################ C: AUTHENTICATE -S: NO ManageSieve syntax is incorrect : You must specify a SASL mechanism as an argument of AUTHENTICATE command +S: NO "ManageSieve syntax is incorrect: quoted SASL mechanism must be supplied" C: AUTHENTICATE "UNKNOWN" -S: NO Unknown SASL mechanism UNKNOWN +S: NO "Unknown SASL mechanism UNKNOWN" C: AUTHENTICATE "PLAIN" S: \+ "" C: GETSCRIPT toto.sieve -S: NO ManageSieve syntax is incorrect : You must supply a password for the authentication mechanism. Formal syntax : usernamepassword +S: NO "Authentication failed with: Verification of credentials failed" -C: tin password -S: NO authentication failed +C: AUTHENTICATE "PLAIN" +S: \+ "" +C: +S: NO "ManageSieve syntax is incorrect: authentication data must be supplied" + +C: AUTHENTICATE "PLAIN" +S: \+ "" +C: tin password +S: NO "Authentication failed with: Verification of credentials failed" C: AUTHENTICATE "PLAIN" S: \+ "" -C: user password -S: OK \ No newline at end of file +C: user password +S: OK diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/authenticateBase64.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/authenticateBase64.test index cee22fb254a..720257bbb2c 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/authenticateBase64.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/authenticateBase64.test @@ -18,4 +18,4 @@ ################################################################ C: AUTHENTICATE "PLAIN" "AHVzZXIAcGFzc3dvcmQ=" -S: OK \ No newline at end of file +S: OK diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/capability.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/capability.test index 1b2141b1247..5ae0e4344a3 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/capability.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/capability.test @@ -39,7 +39,7 @@ S: OK C: AUTHENTICATE "PLAIN" S: \+ "" -C: user password +C: user password S: OK C: CAPABILITY @@ -51,4 +51,4 @@ S: "STARTTLS" S: "IMPLEMENTATION" "Apache ManageSieve v1.0" S: "VERSION" "1.0" } -S: OK \ No newline at end of file +S: OK diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/checkscript.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/checkscript.test index 8fc76dd5001..b2df42bd631 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/checkscript.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/checkscript.test @@ -35,7 +35,7 @@ S: NO C: AUTHENTICATE "PLAIN" S: \+ "" -C: user password +C: user password S: OK C: CHECKSCRIPT {99+} @@ -51,4 +51,3 @@ C: #comment C: InvalidSieveCommand C: S: NO "Syntax Error: org.apache.jsieve.parser.generated.ParseException: Encountered "" at line 2, column 21. - diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/deletescript.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/deletescript.test index 5352d2aaf67..6fc30c96205 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/deletescript.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/deletescript.test @@ -25,7 +25,7 @@ S: NO C: AUTHENTICATE "PLAIN" S: \+ "" -C: user password +C: user password S: OK C: DELETESCRIPT "foo" @@ -77,4 +77,4 @@ C: SETACTIVE "mysievescript" S: OK C: DELETESCRIPT "mysievescript" -S: NO \(ACTIVE\) "You may not delete an active script" \ No newline at end of file +S: NO \(ACTIVE\) "You may not delete an active script" diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/getscript.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/getscript.test index aa5ca1d9bb0..9042c1d925e 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/getscript.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/getscript.test @@ -25,7 +25,7 @@ S: NO C: AUTHENTICATE "PLAIN" S: \+ "" -C: user password +C: user password S: OK C: GETSCRIPT "foo" @@ -48,5 +48,3 @@ S: fileinto "INBOX.sent"; S: \} S: S: OK - - diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/havespace.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/havespace.test index 2b0958276e6..2e742f9912b 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/havespace.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/havespace.test @@ -28,11 +28,11 @@ S: NO C: AUTHENTICATE "PLAIN" S: \+ "" -C: user password +C: user password S: OK C: HAVESPACE "scriptname" 49 S: OK C: HAVESPACE "scriptname" 51 -S: NO \(QUOTA/MAXSIZE\) "Quota exceeded" \ No newline at end of file +S: NO \(QUOTA/MAXSIZE\) "Quota exceeded" diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/listscripts.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/listscripts.test index 1539277af0c..f470c37fc06 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/listscripts.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/listscripts.test @@ -22,7 +22,7 @@ S: NO C: AUTHENTICATE "PLAIN" S: \+ "" -C: user password +C: user password S: OK C: LISTSCRIPTS diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/logout.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/logout.test index 125c4210e4e..65bb0a2cc54 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/logout.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/logout.test @@ -18,4 +18,4 @@ ################################################################ C: LOGOUT -S: OK channel is closing \ No newline at end of file +S: OK channel is closing diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/noop.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/noop.test index 837fb930012..bebba0bd4aa 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/noop.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/noop.test @@ -25,4 +25,4 @@ S: OK \(TAG \{16\} S: STARTTLS-SYNC-42\) "DONE" C: NooP -S: OK "NOOP completed" \ No newline at end of file +S: OK "NOOP completed" diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/putscript.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/putscript.test index e8e300f64d2..e481bfe634a 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/putscript.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/putscript.test @@ -40,7 +40,7 @@ S: NO C: AUTHENTICATE "PLAIN" S: \+ "" -C: user password +C: user password S: OK C: PUTSCRIPT "mysievescript" {97+} @@ -56,4 +56,3 @@ C: #comment C: InvalidSieveCommand C: S: NO "Syntax Error: org.apache.jsieve.parser.generated.ParseException: Encountered "" at line 2, column 21. - diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/renamescript.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/renamescript.test index 355cd844610..2472f3b81ca 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/renamescript.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/renamescript.test @@ -28,7 +28,7 @@ S: NO C: AUTHENTICATE "PLAIN" S: \+ "" -C: user password +C: user password S: OK C: PUTSCRIPT "mysievescript" {99+} @@ -75,5 +75,3 @@ S: OK C: RENAMESCRIPT "mysievescript" "mysievescriptbis" S: NO \(ALREADYEXISTS\) "A script with that name already exists" - - diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/setactive.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/setactive.test index c05ff82e95e..e232ef0d606 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/setactive.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/setactive.test @@ -25,7 +25,7 @@ S: NO C: AUTHENTICATE "PLAIN" S: \+ "" -C: user password +C: user password S: OK C: SETACTIVE "foo" diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/starttls.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/starttls.test index 7e973540b43..6e1526c13d9 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/starttls.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/starttls.test @@ -25,10 +25,10 @@ S: NO You can't enable two time SSL encryption C: AUTHENTICATE "PLAIN" S: \+ "" -C: user password +C: user password S: OK C: STARTTLS S: NO command STARTTLS is issued in the wrong state. It must be issued as you are unauthenticated -C: LOGOUT \ No newline at end of file +C: LOGOUT diff --git a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/unauthenticate.test b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/unauthenticate.test index 4acc436f4af..b2da7480c04 100644 --- a/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/unauthenticate.test +++ b/mpt/impl/managesieve/core/src/main/resources/org/apache/james/managesieve/scripts/unauthenticate.test @@ -28,7 +28,7 @@ S: NO UNAUTHENTICATE command must be issued in authenticated state C: AUTHENTICATE "PLAIN" S: \+ "" -C: user password +C: user password S: OK C: GETSCRIPT any @@ -38,4 +38,4 @@ C: UNAUTHENTICATE S: OK C: GETSCRIPT any -S: NO \ No newline at end of file +S: NO diff --git a/protocols/api/src/main/java/org/apache/james/protocols/api/OIDCSASLParser.java b/protocols/api/src/main/java/org/apache/james/protocols/api/OIDCSASLParser.java index eb2f556f75b..1623998c954 100644 --- a/protocols/api/src/main/java/org/apache/james/protocols/api/OIDCSASLParser.java +++ b/protocols/api/src/main/java/org/apache/james/protocols/api/OIDCSASLParser.java @@ -33,9 +33,11 @@ public class OIDCSASLParser { public static final char SASL_SEPARATOR = 1; public static final String PREFIX_TOKEN = "Bearer "; public static final String TOKEN_PART_PREFIX = "auth="; - public static final String USER_PART_PREFIX = "user="; + public static final String XOAUTH2_USER_PART_PREFIX = "user="; + public static final String OAUTHBEARER_USER_PART_PREFIX = "a="; public static final int TOKEN_PART_INDEX = TOKEN_PART_PREFIX.length(); - public static final int USER_PART_INDEX = USER_PART_PREFIX.length(); + public static final int XOAUTH2_USER_PART_INDEX = XOAUTH2_USER_PART_PREFIX.length(); + public static final int OAUTHBEARER_USER_PART_INDEX = OAUTHBEARER_USER_PART_PREFIX.length(); public static class OIDCInitialResponse { private final String associatedUser; @@ -59,6 +61,7 @@ public static Optional parse(String initialResponse) { Optional decodeResult = decodeBase64(initialResponse); if (decodeResult.isPresent()) { + // See the format of the gs2-header in https://www.rfc-editor.org/rfc/rfc5801#section-4. String decodeValueWithoutDanglingPart = decodeResult.filter(value -> value.startsWith("n,")) .map(value -> value.substring(2)) .orElse(decodeResult.get()); @@ -74,8 +77,15 @@ public static Optional parse(String initialResponse) { if (stringToken.startsWith(TOKEN_PART_PREFIX)) { tokenPart = StringUtils.replace(stringToken.substring(TOKEN_PART_INDEX), PREFIX_TOKEN, ""); tokenPartCounter++; - } else if (stringToken.startsWith(USER_PART_PREFIX)) { - userPart = stringToken.substring(USER_PART_INDEX); + } else if (stringToken.startsWith(XOAUTH2_USER_PART_PREFIX)) { + userPart = stringToken.substring(XOAUTH2_USER_PART_INDEX); + userPartCounter++; + } else if (stringToken.startsWith(OAUTHBEARER_USER_PART_PREFIX)) { + userPart = stringToken.substring(OAUTHBEARER_USER_PART_INDEX); + // See the format of the gs2-header in https://www.rfc-editor.org/rfc/rfc5801#section-4. + if (userPart.endsWith(",")) { + userPart = userPart.substring(0, userPart.length() - 1); + } userPartCounter++; } } diff --git a/protocols/api/src/test/java/org/apache/james/protocols/api/OIDCSASLHelper.java b/protocols/api/src/test/java/org/apache/james/protocols/api/OIDCSASLHelper.java index 1436649a18c..8440a51de04 100644 --- a/protocols/api/src/test/java/org/apache/james/protocols/api/OIDCSASLHelper.java +++ b/protocols/api/src/test/java/org/apache/james/protocols/api/OIDCSASLHelper.java @@ -25,9 +25,19 @@ import com.google.common.collect.ImmutableList; public class OIDCSASLHelper { - public static String generateOauthBearer(String username, String token) { + // See the XOAUTH2 specification at https://developers.google.com/workspace/gmail/imap/xoauth2-protocol + // for details. + public static String generateEncodedXOauth2InitialClientResponse(String username, String token) { return Base64.getEncoder().encodeToString(String.join("" + OIDCSASLParser.SASL_SEPARATOR, ImmutableList.of("user=" + username, "auth=Bearer " + token, "", "")) .getBytes(StandardCharsets.US_ASCII)); } + + // See the OAUTHBEARER specification at https://www.rfc-editor.org/rfc/rfc7628.html#section-3.1 + // and the GSS-API specification at https://www.rfc-editor.org/rfc/rfc5801#section-4 for details. + public static String generateEncodedOauthbearerInitialClientResponse(String username, String token) { + return Base64.getEncoder().encodeToString(String.join("" + OIDCSASLParser.SASL_SEPARATOR, + ImmutableList.of("n,a=" + username + ",", "auth=Bearer " + token, "", "")) + .getBytes(StandardCharsets.US_ASCII)); + } } diff --git a/protocols/managesieve/pom.xml b/protocols/managesieve/pom.xml index fb367c75f5e..5522b6fa883 100644 --- a/protocols/managesieve/pom.xml +++ b/protocols/managesieve/pom.xml @@ -41,11 +41,19 @@ ${james.groupId} james-server-data-api + + ${james.groupId} + james-server-jwt + ${james.groupId} testing-base test + + ${james.protocols.groupId} + protocols-api + com.google.guava guava diff --git a/protocols/managesieve/src/main/java/org/apache/james/managesieve/api/CapabilityAdvertiser.java b/protocols/managesieve/src/main/java/org/apache/james/managesieve/api/CapabilityAdvertiser.java deleted file mode 100644 index de8c12ad106..00000000000 --- a/protocols/managesieve/src/main/java/org/apache/james/managesieve/api/CapabilityAdvertiser.java +++ /dev/null @@ -1,27 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one - * or more contributor license agreements. See the NOTICE file - * distributed with this work for additional information - * regarding copyright ownership. The ASF licenses this file - * to you under the Apache License, Version 2.0 (the - * "License"); you may not use this file except in compliance - * with the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - * - */ - -package org.apache.james.managesieve.api; - -public interface CapabilityAdvertiser { - - String getAdvertisedCapabilities(); - -} diff --git a/protocols/managesieve/src/main/java/org/apache/james/managesieve/api/Session.java b/protocols/managesieve/src/main/java/org/apache/james/managesieve/api/Session.java index c33293d8ee4..9f1a058f19f 100644 --- a/protocols/managesieve/src/main/java/org/apache/james/managesieve/api/Session.java +++ b/protocols/managesieve/src/main/java/org/apache/james/managesieve/api/Session.java @@ -20,7 +20,10 @@ package org.apache.james.managesieve.api; +import java.util.Optional; + import org.apache.james.core.Username; +import org.apache.james.jwt.OidcSASLConfiguration; import org.apache.james.managesieve.api.commands.Authenticate; public interface Session { @@ -51,4 +54,7 @@ enum State { boolean isSslEnabled(); + Optional getOidcSASLConfiguration(); + + void setOidcSASLConfiguration(Optional configuration); } diff --git a/protocols/managesieve/src/main/java/org/apache/james/managesieve/api/commands/Authenticate.java b/protocols/managesieve/src/main/java/org/apache/james/managesieve/api/commands/Authenticate.java index 02c05311d8b..4e4820cd905 100644 --- a/protocols/managesieve/src/main/java/org/apache/james/managesieve/api/commands/Authenticate.java +++ b/protocols/managesieve/src/main/java/org/apache/james/managesieve/api/commands/Authenticate.java @@ -30,7 +30,7 @@ public interface Authenticate { enum SupportedMechanism { - PLAIN; + PLAIN, XOAUTH2, OAUTHBEARER; public static SupportedMechanism retrieveMechanism(String serializedData) throws UnknownSaslMechanism { for (SupportedMechanism supportedMechanism : SupportedMechanism.values()) { diff --git a/protocols/managesieve/src/main/java/org/apache/james/managesieve/api/commands/CoreCommands.java b/protocols/managesieve/src/main/java/org/apache/james/managesieve/api/commands/CoreCommands.java index 092472a9552..6016b9c192d 100644 --- a/protocols/managesieve/src/main/java/org/apache/james/managesieve/api/commands/CoreCommands.java +++ b/protocols/managesieve/src/main/java/org/apache/james/managesieve/api/commands/CoreCommands.java @@ -20,15 +20,12 @@ package org.apache.james.managesieve.api.commands; -import org.apache.james.managesieve.api.CapabilityAdvertiser; - /** * Core RFC 5804 Commands common to all transports * * @see RFC 5804 Commands */ public interface CoreCommands extends Capability, CheckScript, DeleteScript, GetScript, HaveSpace, - ListScripts, PutScript, RenameScript, SetActive, Noop, Unauthenticate, Logout, Authenticate, StartTLS, - CapabilityAdvertiser { + ListScripts, PutScript, RenameScript, SetActive, Noop, Unauthenticate, Logout, Authenticate, StartTLS { } diff --git a/protocols/managesieve/src/main/java/org/apache/james/managesieve/core/CoreProcessor.java b/protocols/managesieve/src/main/java/org/apache/james/managesieve/core/CoreProcessor.java index c4bbe10691a..0e44c9c33df 100644 --- a/protocols/managesieve/src/main/java/org/apache/james/managesieve/core/CoreProcessor.java +++ b/protocols/managesieve/src/main/java/org/apache/james/managesieve/core/CoreProcessor.java @@ -22,7 +22,6 @@ import java.io.IOException; import java.nio.charset.StandardCharsets; -import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; @@ -57,7 +56,6 @@ import com.google.common.base.Joiner; import com.google.common.base.Splitter; import com.google.common.base.Strings; -import com.google.common.collect.Lists; import com.google.common.collect.Maps; public class CoreProcessor implements CoreCommands { @@ -83,11 +81,6 @@ public CoreProcessor(SieveRepository repository, UsersRepository usersRepository this.authenticationProcessorMap.put(SupportedMechanism.PLAIN, new PlainAuthenticationProcessor(usersRepository)); } - @Override - public String getAdvertisedCapabilities() { - return convertCapabilityMapToString(capabilitiesBase) + "\r\n"; - } - @Override public String capability(Session session) { return convertCapabilityMapToString(computeCapabilityMap(session)) + "\r\nOK"; @@ -106,6 +99,11 @@ private Map computeCapabilityMap(Session session) { if (session.isAuthenticated()) { capabilities.put(Capabilities.OWNER, session.getUser().asString()); } + session.getOidcSASLConfiguration().ifPresent(oidcConfiguration -> { + this.authenticationProcessorMap.putIfAbsent(SupportedMechanism.XOAUTH2, new OAUTHAuthenticationProcessor(oidcConfiguration)); + this.authenticationProcessorMap.putIfAbsent(SupportedMechanism.OAUTHBEARER, new OAUTHAuthenticationProcessor(oidcConfiguration)); + }); + capabilities.put(Capabilities.SASL, constructSaslSupportedAuthenticationMechanisms()); return capabilities; } @@ -214,17 +212,24 @@ public String noop(String tag) { public String chooseMechanism(Session session, String mechanism) { try { if (Strings.isNullOrEmpty(mechanism)) { - return "NO ManageSieve syntax is incorrect : You must specify a SASL mechanism as an argument of AUTHENTICATE command"; + throw new SyntaxException("quoted SASL mechanism must be supplied"); + } + + SupportedMechanism supportedMechanism = SupportedMechanism.retrieveMechanism(mechanism); + if (!this.authenticationProcessorMap.containsKey(supportedMechanism)) { + throw new UnknownSaslMechanism("SASL mechanism disabled: " + mechanism); } - String unquotedMechanism = ParserUtils.unquoteFirst(mechanism); - SupportedMechanism supportedMechanism = SupportedMechanism.retrieveMechanism(unquotedMechanism); session.setChoosedAuthenticationMechanism(supportedMechanism); session.setState(Session.State.AUTHENTICATION_IN_PROGRESS); AuthenticationProcessor authenticationProcessor = authenticationProcessorMap.get(supportedMechanism); return authenticationProcessor.initialServerResponse(session); - } catch (UnknownSaslMechanism unknownSaslMechanism) { - return "NO " + unknownSaslMechanism.getMessage(); + } catch (UnknownSaslMechanism e) { + resetSession(session); + return "NO \"" + e.getMessage() + "\""; + } catch (SyntaxException e) { + resetSession(session); + return "NO \"ManageSieve syntax is incorrect: " + e.getMessage() + "\""; } } @@ -233,34 +238,46 @@ public String authenticate(Session session, String suppliedData) { try { SupportedMechanism currentAuthenticationMechanism = session.getChoosedAuthenticationMechanism(); AuthenticationProcessor authenticationProcessor = authenticationProcessorMap.get(currentAuthenticationMechanism); + if (Strings.isNullOrEmpty(suppliedData)) { + throw new SyntaxException("authentication data must be supplied"); + } + if (suppliedData.equals("*")) { + throw new AuthenticationException("authentication aborted by client"); + } Username authenticatedUsername = authenticationProcessor.isAuthenticationSuccesfull(session, suppliedData); if (authenticatedUsername != null) { session.setUser(authenticatedUsername); session.setState(Session.State.AUTHENTICATED); return "OK"; } else { - session.setState(Session.State.UNAUTHENTICATED); - session.setUser(null); - return "NO authentication failed"; + resetSession(session); + return "NO \"authentication failed\""; } } catch (AuthenticationException e) { - return "NO Authentication failed with " + e.getCause().getClass() + " : " + e.getMessage(); + resetSession(session); + return "NO \"Authentication failed with: " + e.getMessage() + "\""; } catch (SyntaxException e) { - return "NO ManageSieve syntax is incorrect : " + e.getMessage(); + resetSession(session); + return "NO \"ManageSieve syntax is incorrect: " + e.getMessage() + "\""; } } @Override public String unauthenticate(Session session) { if (session.isAuthenticated()) { - session.setState(Session.State.UNAUTHENTICATED); - session.setUser(null); + resetSession(session); return "OK"; } else { return "NO UNAUTHENTICATE command must be issued in authenticated state"; } } + private static void resetSession(Session session) { + session.setState(Session.State.UNAUTHENTICATED); + session.setUser(null); + session.setChoosedAuthenticationMechanism(null); + } + @Override public void logout() throws SessionTerminatedException { throw new SessionTerminatedException(); @@ -328,7 +345,6 @@ private Map precomputedCapabilitiesBase(SieveParser parser Map capabilitiesBase = new HashMap<>(); capabilitiesBase.put(Capabilities.IMPLEMENTATION, IMPLEMENTATION_DESCRIPTION); capabilitiesBase.put(Capabilities.VERSION, MANAGE_SIEVE_VERSION); - capabilitiesBase.put(Capabilities.SASL, constructSaslSupportedAuthenticationMechanisms()); capabilitiesBase.put(Capabilities.STARTTLS, null); if (!extensions.isEmpty()) { capabilitiesBase.put(Capabilities.SIEVE, extensions); @@ -337,10 +353,12 @@ private Map precomputedCapabilitiesBase(SieveParser parser } private String constructSaslSupportedAuthenticationMechanisms() { - return Joiner.on(' ') - .join(Lists.transform( - Arrays.asList(SupportedMechanism.values()), - Enum::toString)); + return Joiner.on(' ').join(this.authenticationProcessorMap + .keySet() + .stream() + .map(Enum::toString) + .iterator() + ); } private String sanitizeString(String message) { diff --git a/protocols/managesieve/src/main/java/org/apache/james/managesieve/core/OAUTHAuthenticationProcessor.java b/protocols/managesieve/src/main/java/org/apache/james/managesieve/core/OAUTHAuthenticationProcessor.java new file mode 100644 index 00000000000..ebdfe25c332 --- /dev/null +++ b/protocols/managesieve/src/main/java/org/apache/james/managesieve/core/OAUTHAuthenticationProcessor.java @@ -0,0 +1,75 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + * + */ + +package org.apache.james.managesieve.core; + +import java.util.Optional; + +import org.apache.james.core.Username; +import org.apache.james.jwt.OidcJwtTokenVerifier; +import org.apache.james.jwt.OidcSASLConfiguration; +import org.apache.james.managesieve.api.AuthenticationException; +import org.apache.james.managesieve.api.AuthenticationProcessor; +import org.apache.james.managesieve.api.Session; +import org.apache.james.managesieve.api.SyntaxException; +import org.apache.james.protocols.api.OIDCSASLParser; +import org.apache.james.protocols.api.OIDCSASLParser.OIDCInitialResponse; + +public class OAUTHAuthenticationProcessor implements AuthenticationProcessor { + + private final OidcSASLConfiguration oidcConfiguration; + + public OAUTHAuthenticationProcessor(OidcSASLConfiguration oidcConfiguration) { + this.oidcConfiguration = oidcConfiguration; + } + + @Override + public String initialServerResponse(Session session) { + return "+ \"\""; + } + + @Override + public Username isAuthenticationSuccesfull(Session session, String suppliedClientData) throws SyntaxException, AuthenticationException { + Optional oidcInitialResponseResult = OIDCSASLParser.parse(suppliedClientData); + if (oidcInitialResponseResult.isEmpty()) { + throw new SyntaxException("Could not parse the given JWT"); + } + OIDCInitialResponse oidcInitialResponse = oidcInitialResponseResult.get(); + + Optional authenticatedUserResult = Optional.empty(); + try { + authenticatedUserResult = new OidcJwtTokenVerifier(this.oidcConfiguration).validateToken(oidcInitialResponse.getToken()); + } catch (Exception e) { + throw new AuthenticationException("Could not validate the JWT"); + } + if (authenticatedUserResult.isEmpty()) { + throw new AuthenticationException("Could not validate the JWT"); + } + Username authenticatedUser = authenticatedUserResult.get(); + + // The user from the managesieve AUTHENTICATE command must match the username in the token. + Username associatedUser = Username.of(oidcInitialResponse.getAssociatedUser()); + if (!authenticatedUser.equals(associatedUser)) { + throw new AuthenticationException("Mismatch between user from command and JWT"); + } + + return authenticatedUser; + } +} diff --git a/protocols/managesieve/src/main/java/org/apache/james/managesieve/core/PlainAuthenticationProcessor.java b/protocols/managesieve/src/main/java/org/apache/james/managesieve/core/PlainAuthenticationProcessor.java index f59daedb9a6..1e9e6596380 100644 --- a/protocols/managesieve/src/main/java/org/apache/james/managesieve/core/PlainAuthenticationProcessor.java +++ b/protocols/managesieve/src/main/java/org/apache/james/managesieve/core/PlainAuthenticationProcessor.java @@ -72,11 +72,11 @@ public Username isAuthenticationSuccesfull(Session session, String suppliedClien private Username authenticateWithSeparator(Session session, String suppliedClientData, char c) throws SyntaxException, AuthenticationException { Iterator it = Splitter.on(c).omitEmptyStrings().split(suppliedClientData).iterator(); if (!it.hasNext()) { - throw new SyntaxException("You must supply a username for the authentication mechanism. Formal syntax : usernamepassword"); + throw new SyntaxException("You must supply a username for the authentication mechanism. Formal syntax: usernamepassword"); } Username userName = Username.of(it.next()); if (!it.hasNext()) { - throw new SyntaxException("You must supply a password for the authentication mechanism. Formal syntax : usernamepassword"); + throw new SyntaxException("You must supply a password for the authentication mechanism. Formal syntax: usernamepassword"); } String password = it.next(); session.setUser(userName); @@ -85,7 +85,7 @@ private Username authenticateWithSeparator(Session session, String suppliedClien if (user != null && user.verifyPassword(password)) { return user.getUserName(); } else { - return null; + throw new AuthenticationException("Verification of credentials failed"); } } catch (UsersRepositoryException e) { throw new AuthenticationException(e.getMessage()); diff --git a/protocols/managesieve/src/main/java/org/apache/james/managesieve/transcode/ArgumentParser.java b/protocols/managesieve/src/main/java/org/apache/james/managesieve/transcode/ArgumentParser.java index e9f78f2629c..c6d2cb16f6e 100644 --- a/protocols/managesieve/src/main/java/org/apache/james/managesieve/transcode/ArgumentParser.java +++ b/protocols/managesieve/src/main/java/org/apache/james/managesieve/transcode/ArgumentParser.java @@ -53,10 +53,6 @@ public ArgumentParser(CoreCommands core, boolean validatePutSize) { this.validatePutSize = validatePutSize; } - public String getAdvertisedCapabilities() { - return core.getAdvertisedCapabilities(); - } - public String capability(Session session, String args) { if (!args.trim().isEmpty()) { return "NO \"Too many arguments: " + args + "\""; diff --git a/protocols/managesieve/src/main/java/org/apache/james/managesieve/transcode/ManageSieveProcessor.java b/protocols/managesieve/src/main/java/org/apache/james/managesieve/transcode/ManageSieveProcessor.java index a68359ce8fe..391e9203b9a 100644 --- a/protocols/managesieve/src/main/java/org/apache/james/managesieve/transcode/ManageSieveProcessor.java +++ b/protocols/managesieve/src/main/java/org/apache/james/managesieve/transcode/ManageSieveProcessor.java @@ -22,10 +22,10 @@ import jakarta.inject.Inject; -import org.apache.commons.lang3.StringUtils; import org.apache.james.managesieve.api.ManageSieveException; import org.apache.james.managesieve.api.Session; import org.apache.james.managesieve.api.SessionTerminatedException; +import org.apache.james.managesieve.util.ParserUtils; import org.apache.james.sieverepository.api.exception.SieveRepositoryException; public class ManageSieveProcessor { @@ -54,6 +54,17 @@ public ManageSieveProcessor(ArgumentParser argumentParser) { } public String handleRequest(Session session, String request) throws ManageSieveException, SieveRepositoryException { + if (request.endsWith("\n")) { + request = request.substring(0, request.length() - 1); + } + if (request.endsWith("\r")) { + request = request.substring(0, request.length() - 1); + } + + if (session.getState() == Session.State.AUTHENTICATION_IN_PROGRESS) { + return matchCommandWithImplementation(session, request.trim(), AUTHENTICATE) + "\r\n"; + } + int firstWordEndIndex = request.indexOf(' '); String arguments = parseArguments(request, firstWordEndIndex); String command = parseCommand(request, firstWordEndIndex); @@ -67,12 +78,6 @@ private String parseCommand(String request, int firstWordEndIndex) { } else { command = request; } - if (command.endsWith("\n")) { - command = command.substring(0, command.length() - 1); - } - if (command.endsWith("\r")) { - command = command.substring(0, command.length() - 1); - } return command; } @@ -85,20 +90,37 @@ private String parseArguments(String request, int firstWordEndIndex) { } private String matchCommandWithImplementation(Session session, String arguments, String command) throws SessionTerminatedException { - if (session.getState() == Session.State.AUTHENTICATION_IN_PROGRESS) { - return argumentParser.authenticate(session, arguments); - } if (command.equalsIgnoreCase(AUTHENTICATE)) { - if (StringUtils.countMatches(arguments, "\"") == 4) { - argumentParser.chooseMechanism(session, arguments); - int bracket1 = arguments.indexOf('\"'); - int bracket2 = arguments.indexOf('\"', bracket1 + 1); - int bracket3 = arguments.indexOf('\"', bracket2 + 1); - int bracket4 = arguments.indexOf('\"', bracket3 + 1); - - return argumentParser.authenticate(session, arguments.substring(bracket3 + 1, bracket4)); + // The RFC forbids the AUTHENTICATE command if the session is already authenticated. + if (session.isAuthenticated()) { + return "NO \"already authenticated\""; + } + + // If no authentication is in progress, the authentication mechanism needs to be chosen. + if (session.getState() != Session.State.AUTHENTICATION_IN_PROGRESS) { + String mechanism = ParserUtils.unquoteFirst(arguments); + String result = argumentParser.chooseMechanism(session, mechanism); + // If the authentication is not in progress, return the result (error) because choosing the mechanism has failed. + if (session.getState() != Session.State.AUTHENTICATION_IN_PROGRESS) { + return result; + } + + // Skips the whole mechanism, the closing quote, and the space if present. + // If the request is well-formatted, the arguments are now empty or contain the client's initial response. + arguments = arguments.substring(arguments.indexOf(mechanism) + mechanism.length() + 1); + if (arguments.startsWith(" ")) { + arguments = arguments.substring(1); + } + // If there are is no initial client response left, return the result (initial server response). + if (arguments.isEmpty()) { + return result; + } + // Unquote the argument in this case because continuation is not used. + arguments = ParserUtils.unquoteFirst(arguments); } - return argumentParser.chooseMechanism(session, arguments); + + // The authentication is in progress, the mechanism has been chosen, and the arguments contain an initial client response. + return argumentParser.authenticate(session, arguments); } else if (command.equalsIgnoreCase(CAPABILITY)) { return argumentParser.capability(session, arguments); } else if (command.equalsIgnoreCase(CHECKSCRIPT)) { @@ -129,8 +151,8 @@ private String matchCommandWithImplementation(Session session, String arguments, return "NO unknown " + command + " command"; } - public String getAdvertisedCapabilities() { - return argumentParser.getAdvertisedCapabilities(); + public String getAdvertisedCapabilities(Session session) { + return argumentParser.capability(session, "") + "\r\n"; } } diff --git a/protocols/managesieve/src/main/java/org/apache/james/managesieve/util/ParserUtils.java b/protocols/managesieve/src/main/java/org/apache/james/managesieve/util/ParserUtils.java index f07198843f3..86fbd7c02d5 100644 --- a/protocols/managesieve/src/main/java/org/apache/james/managesieve/util/ParserUtils.java +++ b/protocols/managesieve/src/main/java/org/apache/james/managesieve/util/ParserUtils.java @@ -56,8 +56,8 @@ public static String unquoteFirst(String quoted) { } if (quoted.length() > 2 && quoted.startsWith("\"") && quoted.indexOf('\"', 1) >= 0) { return quoted.substring(1, quoted.indexOf('\"', 1)); - } else if (quoted.startsWith("'") && quoted.endsWith("'")) { - return quoted.substring(1, quoted.length() - 1); + } else if (quoted.length() > 2 && quoted.startsWith("'") && quoted.indexOf('\'', 1) >= 0) { + return quoted.substring(1, quoted.indexOf('\'', 1)); } return null; } diff --git a/protocols/managesieve/src/main/java/org/apache/james/managesieve/util/SettableSession.java b/protocols/managesieve/src/main/java/org/apache/james/managesieve/util/SettableSession.java index 09b49dea6e2..204a39e6881 100644 --- a/protocols/managesieve/src/main/java/org/apache/james/managesieve/util/SettableSession.java +++ b/protocols/managesieve/src/main/java/org/apache/james/managesieve/util/SettableSession.java @@ -20,7 +20,10 @@ package org.apache.james.managesieve.util; +import java.util.Optional; + import org.apache.james.core.Username; +import org.apache.james.jwt.OidcSASLConfiguration; import org.apache.james.managesieve.api.Session; import org.apache.james.managesieve.api.commands.Authenticate; @@ -30,6 +33,7 @@ public class SettableSession implements Session { private State state; private Authenticate.SupportedMechanism choosedAuthenticationMechanism; private boolean sslEnabled; + private Optional oidcSASLConfiguration = Optional.empty(); public SettableSession() { this.state = State.UNAUTHENTICATED; @@ -80,4 +84,14 @@ public void setSslEnabled(boolean sslEnabled) { public boolean isSslEnabled() { return sslEnabled; } + + @Override + public Optional getOidcSASLConfiguration() { + return this.oidcSASLConfiguration; + } + + @Override + public void setOidcSASLConfiguration(Optional configuration) { + this.oidcSASLConfiguration = configuration; + } } diff --git a/server/protocols/jwt/src/main/java/org/apache/james/jwt/OidcJwtTokenVerifier.java b/server/protocols/jwt/src/main/java/org/apache/james/jwt/OidcJwtTokenVerifier.java index 7f87132bb7c..28af294b64e 100644 --- a/server/protocols/jwt/src/main/java/org/apache/james/jwt/OidcJwtTokenVerifier.java +++ b/server/protocols/jwt/src/main/java/org/apache/james/jwt/OidcJwtTokenVerifier.java @@ -104,7 +104,7 @@ private Predicate validateAud(String expectedAud) { } @VisibleForTesting - Publisher verifyWithUserinfo(String jwtToken, URL userinfoEndpoint) { + Publisher verifyWithUserinfo(String jwtToken, URL userinfoEndpoint) { return Mono.fromCallable(() -> verifySignatureAndExtractClaim(jwtToken)) .flatMap(optional -> optional.map(Mono::just).orElseGet(Mono::empty)) .flatMap(claimResult -> Mono.from(CHECK_TOKEN_CLIENT.userInfo(userinfoEndpoint, jwtToken)) diff --git a/server/protocols/jwt/src/main/java/org/apache/james/jwt/OidcSASLConfiguration.java b/server/protocols/jwt/src/main/java/org/apache/james/jwt/OidcSASLConfiguration.java index cb59ef1811b..0fbc52f46e4 100644 --- a/server/protocols/jwt/src/main/java/org/apache/james/jwt/OidcSASLConfiguration.java +++ b/server/protocols/jwt/src/main/java/org/apache/james/jwt/OidcSASLConfiguration.java @@ -37,9 +37,6 @@ public class OidcSASLConfiguration { private static final Logger LOGGER = LoggerFactory.getLogger(OidcSASLConfiguration.class); - private static final boolean FORCE_INTROSPECT = Boolean.parseBoolean(System.getProperty("james.sasl.oidc.force.introspect", "true")); - private static final boolean VALIDATE_AUD = Boolean.parseBoolean(System.getProperty("james.sasl.oidc.validate.aud", "true")); - @VisibleForTesting static Builder builder() { return new Builder(); @@ -140,7 +137,7 @@ public static OidcSASLConfiguration parse(HierarchicalConfigurationApache James :: Server :: ManageSieve + + ${james.groupId} + james-server-data-file + test + + + ${james.groupId} + james-server-data-memory + test + + + ${james.groupId} + james-server-filesystem-api + ${james.groupId} james-server-filesystem-api + test-jar + test + + + ${james.groupId} + james-server-jwt + test-jar + test ${james.groupId} james-server-protocols-library + + ${james.groupId} + james-server-protocols-library + test-jar + test + ${james.groupId} james-server-util @@ -30,6 +58,16 @@ testing-base test + + ${james.protocols.groupId} + protocols-api + + + ${james.protocols.groupId} + protocols-api + test-jar + test + ${james.protocols.groupId} protocols-managesieve @@ -38,6 +76,11 @@ ${james.protocols.groupId} protocols-netty + + commons-net + commons-net + test + io.netty netty-handler @@ -54,6 +97,16 @@ org.apache.commons commons-configuration2 + + org.apache.commons + commons-lang3 + test + + + org.mock-server + mockserver-netty + test + org.slf4j jcl-over-slf4j diff --git a/server/protocols/protocols-managesieve/src/main/java/org/apache/james/managesieveserver/netty/ManageSieveChannelUpstreamHandler.java b/server/protocols/protocols-managesieve/src/main/java/org/apache/james/managesieveserver/netty/ManageSieveChannelUpstreamHandler.java index 444d426351e..134d1b375c4 100644 --- a/server/protocols/protocols-managesieve/src/main/java/org/apache/james/managesieveserver/netty/ManageSieveChannelUpstreamHandler.java +++ b/server/protocols/protocols-managesieve/src/main/java/org/apache/james/managesieveserver/netty/ManageSieveChannelUpstreamHandler.java @@ -21,7 +21,9 @@ import java.io.Closeable; import java.net.InetSocketAddress; +import java.util.Optional; +import org.apache.james.jwt.OidcSASLConfiguration; import org.apache.james.managesieve.api.Session; import org.apache.james.managesieve.api.SessionTerminatedException; import org.apache.james.managesieve.transcode.ManageSieveProcessor; @@ -51,12 +53,14 @@ public class ManageSieveChannelUpstreamHandler extends ChannelInboundHandlerAdap private final ManageSieveProcessor manageSieveProcessor; private final Encryption secure; private final int maxLineLength; + private final Optional oidcConfiguration; public ManageSieveChannelUpstreamHandler( - ManageSieveProcessor manageSieveProcessor, Encryption secure, int maxLineLength) { + ManageSieveProcessor manageSieveProcessor, Encryption secure, int maxLineLength, Optional oidcConfiguration) { this.manageSieveProcessor = manageSieveProcessor; this.secure = secure; this.maxLineLength = maxLineLength; + this.oidcConfiguration = oidcConfiguration; } private boolean isSSL() { @@ -146,13 +150,14 @@ public void channelActive(ChannelHandlerContext ctx) throws Exception { LOGGER.info("Connection established from {}", address.getAddress().getHostAddress()); Session session = new SettableSession(); + session.setOidcSASLConfiguration(this.oidcConfiguration); if (isSSL()) { session.setSslEnabled(true); } ctx.channel().attr(NettyConstants.SESSION_ATTRIBUTE_KEY).set(session); ctx.channel().attr(NettyConstants.RESPONSE_WRITER_ATTRIBUTE_KEY).set(new ChannelManageSieveResponseWriter(ctx.channel())); super.channelActive(ctx); - ctx.channel().attr(NettyConstants.RESPONSE_WRITER_ATTRIBUTE_KEY).get().write(manageSieveProcessor.getAdvertisedCapabilities() + "OK\r\n"); + ctx.channel().attr(NettyConstants.RESPONSE_WRITER_ATTRIBUTE_KEY).get().write(manageSieveProcessor.getAdvertisedCapabilities(session)); } } diff --git a/server/protocols/protocols-managesieve/src/main/java/org/apache/james/managesieveserver/netty/ManageSieveServer.java b/server/protocols/protocols-managesieve/src/main/java/org/apache/james/managesieveserver/netty/ManageSieveServer.java index 114b0822178..7e1f55af7c7 100644 --- a/server/protocols/protocols-managesieve/src/main/java/org/apache/james/managesieveserver/netty/ManageSieveServer.java +++ b/server/protocols/protocols-managesieve/src/main/java/org/apache/james/managesieveserver/netty/ManageSieveServer.java @@ -19,11 +19,14 @@ package org.apache.james.managesieveserver.netty; +import java.net.MalformedURLException; +import java.net.URISyntaxException; import java.util.Optional; import org.apache.commons.configuration2.HierarchicalConfiguration; import org.apache.commons.configuration2.ex.ConfigurationException; import org.apache.commons.configuration2.tree.ImmutableNode; +import org.apache.james.jwt.OidcSASLConfiguration; import org.apache.james.managesieve.transcode.ManageSieveProcessor; import org.apache.james.protocols.lib.netty.AbstractConfigurableAsyncServer; import org.apache.james.protocols.netty.AbstractChannelPipelineFactory; @@ -48,11 +51,13 @@ public class ManageSieveServer extends AbstractConfigurableAsyncServer implements ManageSieveServerMBean { private static final Logger LOGGER = LoggerFactory.getLogger(ManageSieveServer.class); + static final String OIDC_PATH = "oidc"; private final int maxLineLength; private final ManageSieveProcessor manageSieveProcessor; private Optional connectionLimitUpstreamHandler = Optional.empty(); private Optional connectionPerIpLimitUpstreamHandler = Optional.empty(); + private Optional oidcConfiguration; public ManageSieveServer(int maxLineLength, ManageSieveProcessor manageSieveProcessor) { this.maxLineLength = maxLineLength; @@ -70,6 +75,16 @@ protected void doConfigure(HierarchicalConfiguration config) thro connectionLimitUpstreamHandler = ConnectionLimitUpstreamHandler.forCount(connectionLimit); connectionPerIpLimitUpstreamHandler = ConnectionPerIpLimitUpstreamHandler.forCount(connPerIP); + + if (config.immutableChildConfigurationsAt(OIDC_PATH).isEmpty()) { + this.oidcConfiguration = Optional.empty(); + } else { + try { + this.oidcConfiguration = Optional.of(OidcSASLConfiguration.parse(config.configurationAt(OIDC_PATH))); + } catch (MalformedURLException | NullPointerException | URISyntaxException exception) { + throw new ConfigurationException("Failed to parse OIDC configuration", exception); + } + } } @Override @@ -79,7 +94,7 @@ protected String getDefaultJMXName() { @Override protected ChannelInboundHandlerAdapter createCoreHandler() { - return new ManageSieveChannelUpstreamHandler(manageSieveProcessor, getEncryption(), maxLineLength); + return new ManageSieveChannelUpstreamHandler(manageSieveProcessor, getEncryption(), maxLineLength, this.oidcConfiguration); } @Override diff --git a/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/AuthenticateTest.java b/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/AuthenticateTest.java new file mode 100644 index 00000000000..63f9b86ec0c --- /dev/null +++ b/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/AuthenticateTest.java @@ -0,0 +1,238 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.managesieveserver; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; + +public class AuthenticateTest { + private ManageSieveClient client; + private final ManageSieveServerTestSystem testSystem; + + public AuthenticateTest() throws Exception { + this.testSystem = new ManageSieveServerTestSystem(); + } + + @BeforeEach + void setUp() throws Exception { + this.testSystem.setUp(); + this.client = new ManageSieveClient(); + this.client.connect(this.testSystem.getBindedIP(), this.testSystem.getBindedPort()); + this.client.readResponse(); + } + + @AfterEach + void tearDown() { + this.testSystem.manageSieveServer.destroy(); + } + + @Test + void plainLoginWithCorrectCredentialsShouldSucceed() throws IOException { + this.authenticatePlain(); + } + + @Test + void plainLoginWithWrongPasswordShouldNotSucceed() throws IOException { + String initialClientResponse = "\0" + ManageSieveServerTestSystem.USERNAME.asString() + "\0" + ManageSieveServerTestSystem.PASSWORD + "wrong"; + this.client.sendCommand("AUTHENTICATE \"PLAIN\" \"" + Base64.getEncoder().encodeToString(initialClientResponse.getBytes(StandardCharsets.UTF_8)) + "\""); + ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + } + + @Test + void plainLoginWithNotExistingUserShouldNotSucceed() throws IOException { + String initialClientResponse = "\0" + ManageSieveServerTestSystem.USERNAME.asString() + "not-existing" + "\0" + "pwd"; + this.client.sendCommand("AUTHENTICATE \"PLAIN\" \"" + Base64.getEncoder().encodeToString(initialClientResponse.getBytes(StandardCharsets.UTF_8)) + "\""); + ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + } + + @Test + void plainLoginWithoutPasswordShouldNotSucceed() throws IOException { + String initialClientResponse = "\0" + ManageSieveServerTestSystem.USERNAME.asString() + "\0"; + this.client.sendCommand("AUTHENTICATE \"PLAIN\" \"" + Base64.getEncoder().encodeToString(initialClientResponse.getBytes(StandardCharsets.UTF_8)) + "\""); + ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + } + + // The SASL PLAIN standard (https://datatracker.ietf.org/doc/html/rfc4616) defines the following message: + // message = [authzid] UTF8NUL authcid UTF8NUL passwd + // The current code is more lenient and accepts the message without the first null byte. + @Test + void plainLoginWithoutLeadingNullByteShouldSucceed() throws IOException { + String initialClientResponse = ManageSieveServerTestSystem.USERNAME.asString() + "\0" + ManageSieveServerTestSystem.PASSWORD; + this.client.sendCommand("AUTHENTICATE \"PLAIN\" \"" + Base64.getEncoder().encodeToString(initialClientResponse.getBytes(StandardCharsets.UTF_8)) + "\""); + ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); + } + + // The SASL PLAIN standard defines UTF8NUL as separator. To stay compatible with older versions of James, + // James is more lenient and also supports a space as the delimiter if the message is not base64-encoded. + @Test + void plainLoginWithSpaceAsDelimiterShouldSucceed() throws IOException { + String initialClientResponse = " " + ManageSieveServerTestSystem.USERNAME.asString() + " " + ManageSieveServerTestSystem.PASSWORD; + this.client.sendCommand("AUTHENTICATE \"PLAIN\" \"" + initialClientResponse + "\""); + ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); + } + + // This tests the combination of both lenient behaviors above. + @Test + void plainLoginWithSpaceAsDelimiterWithoutLeadingSpaceShouldSucceed() throws IOException { + String initialClientResponse = ManageSieveServerTestSystem.USERNAME.asString() + " " + ManageSieveServerTestSystem.PASSWORD; + this.client.sendCommand("AUTHENTICATE \"PLAIN\" \"" + initialClientResponse + "\""); + ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); + } + + @Test + void plainLoginWithoutMechanismQuotesShouldNotSucceed() throws IOException { + String initialClientResponse = "\0" + ManageSieveServerTestSystem.USERNAME.asString() + "\0" + ManageSieveServerTestSystem.PASSWORD; + this.client.sendCommand("AUTHENTICATE PLAIN \"" + Base64.getEncoder().encodeToString(initialClientResponse.getBytes(StandardCharsets.UTF_8)) + "\""); + ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + } + + @Test + void plainLoginWithoutInitialResponseQuotesShouldNotSucceed() throws IOException { + String initialClientResponse = "\0" + ManageSieveServerTestSystem.USERNAME.asString() + "\0" + ManageSieveServerTestSystem.PASSWORD; + this.client.sendCommand("AUTHENTICATE \"PLAIN\" " + Base64.getEncoder().encodeToString(initialClientResponse.getBytes(StandardCharsets.UTF_8))); + ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + } + + @Test + void plainLoginWithContinuationShouldSucceed() throws IOException { + this.client.sendCommand("AUTHENTICATE \"PLAIN\""); + ManageSieveClient.ServerResponse continuationResponse = this.client.readResponse(); + Assertions.assertThat(continuationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.CONTINUATION); + Assertions.assertThat(continuationResponse.explanation().get()).isEqualTo(""); + + String initialClientResponse = "\0" + ManageSieveServerTestSystem.USERNAME.asString() + "\0" + ManageSieveServerTestSystem.PASSWORD; + this.client.sendCommand(Base64.getEncoder().encodeToString(initialClientResponse.getBytes(StandardCharsets.UTF_8))); + ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); + } + + @Test + void plainLoginWithContinuationCanBeAborted() throws IOException { + this.client.sendCommand("AUTHENTICATE \"PLAIN\""); + ManageSieveClient.ServerResponse continuationResponse = this.client.readResponse(); + Assertions.assertThat(continuationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.CONTINUATION); + Assertions.assertThat(continuationResponse.explanation().get()).isEqualTo(""); + + this.client.sendCommand("*"); + ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + Assertions.assertThat(authenticationResponse.explanation()).get().isEqualTo("Authentication failed with: authentication aborted by client"); + } + + @Test + void doubleAuthenticationShouldFail() throws IOException { + String initialClientResponse = "\0" + ManageSieveServerTestSystem.USERNAME.asString() + "\0" + ManageSieveServerTestSystem.PASSWORD; + String command = "AUTHENTICATE \"PLAIN\" \"" + Base64.getEncoder().encodeToString(initialClientResponse.getBytes(StandardCharsets.UTF_8)) + "\""; + + this.client.sendCommand(command); + ManageSieveClient.ServerResponse firstAuthenticationResponse = this.client.readResponse(); + Assertions.assertThat(firstAuthenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); + + this.client.sendCommand(command); + ManageSieveClient.ServerResponse secondAuthenticationResponse = this.client.readResponse(); + Assertions.assertThat(secondAuthenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + Assertions.assertThat(secondAuthenticationResponse.explanation()).get().isEqualTo("already authenticated"); + } + + @Test + void unauthenticateInUnauthenticatedStateShouldFail() throws IOException { + this.client.sendCommand("UNAUTHENTICATE"); + ManageSieveClient.ServerResponse response = this.client.readResponse(); + Assertions.assertThat(response.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + } + + @Test + void unauthenticateInAuthenticatedStateShouldSucceed() throws IOException { + this.authenticatePlain(); + + this.client.sendCommand("UNAUTHENTICATE"); + ManageSieveClient.ServerResponse response = this.client.readResponse(); + Assertions.assertThat(response.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); + } + + @Test + void authenticatedStateUnlocksNewCommands() throws IOException { + this.client.sendCommand("LISTSCRIPTS"); + ManageSieveClient.ServerResponse unauthenticatedResponse = this.client.readResponse(); + Assertions.assertThat(unauthenticatedResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + + this.authenticatePlain(); + + this.client.sendCommand("LISTSCRIPTS"); + ManageSieveClient.ServerResponse authenticatedResponse = this.client.readResponse(); + Assertions.assertThat(authenticatedResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); + + this.client.sendCommand("UNAUTHENTICATE"); + ManageSieveClient.ServerResponse response = this.client.readResponse(); + Assertions.assertThat(response.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); + + this.client.sendCommand("LISTSCRIPTS"); + ManageSieveClient.ServerResponse loggedOutResponse = this.client.readResponse(); + Assertions.assertThat(loggedOutResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + } + + // The server actually disconnects but isConnected still returns True. + // Even when adding a delay, it still returns True. + // There is probably something else broken with this test. + @Disabled + @Test + void logoutShouldWorkInUnauthenticatedState() throws IOException, InterruptedException { + this.client.sendCommand("LOGOUT"); + ManageSieveClient.ServerResponse response = this.client.readResponse(); + Assertions.assertThat(response.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); + Assertions.assertThat(this.client.isConnected()).isFalse(); + } + + // The server actually disconnects but isConnected still returns True. + // Even when adding a delay, it still returns True. + // There is probably something else broken with this test. + @Disabled + @Test + void logoutShouldWorkInAuthenticatedState() throws IOException, InterruptedException { + this.authenticatePlain(); + + this.client.sendCommand("LOGOUT"); + ManageSieveClient.ServerResponse response = this.client.readResponse(); + Assertions.assertThat(response.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); + Assertions.assertThat(this.client.isConnected()).isFalse(); + } + + void authenticatePlain() throws IOException { + String initialClientResponse = "\0" + ManageSieveServerTestSystem.USERNAME.asString() + "\0" + ManageSieveServerTestSystem.PASSWORD; + this.client.sendCommand("AUTHENTICATE \"PLAIN\" \"" + Base64.getEncoder().encodeToString(initialClientResponse.getBytes(StandardCharsets.UTF_8)) + "\""); + ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); + } +} diff --git a/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/CapabilityTest.java b/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/CapabilityTest.java new file mode 100644 index 00000000000..f13ffe3d8f2 --- /dev/null +++ b/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/CapabilityTest.java @@ -0,0 +1,74 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.managesieveserver; + +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +class CapabilityTest { + private final ManageSieveServerTestSystem testSystem; + + public CapabilityTest() throws Exception { + this.testSystem = new ManageSieveServerTestSystem(); + } + + @AfterEach + void tearDown() { + this.testSystem.manageSieveServer.destroy(); + } + + @Test + void shouldAnnounceOnlyPlainAuthenticationWithDefaultConfig() throws Exception { + this.testSystem.setUp(); + + ManageSieveClient client = new ManageSieveClient(); + client.connect(this.testSystem.getBindedIP(), this.testSystem.getBindedPort()); + ManageSieveClient.ServerResponse initialGreeting = client.readResponse(); + Assertions.assertThat(getSASLMechanisms(initialGreeting)).containsExactlyInAnyOrder("PLAIN"); + + client.sendCommand("CAPABILITY"); + ManageSieveClient.ServerResponse capabilityResponse = client.readResponse(); + Assertions.assertThat(getSASLMechanisms(capabilityResponse)).containsExactlyInAnyOrder("PLAIN"); + } + + @Test + void shouldAnnouncePlainAndOauthWhenConfigured() throws Exception { + this.testSystem.setUp("managesieveserver-oidc.xml"); + + ManageSieveClient client = new ManageSieveClient(); + client.connect(this.testSystem.getBindedIP(), this.testSystem.getBindedPort()); + ManageSieveClient.ServerResponse initialGreeting = client.readResponse(); + Assertions.assertThat(getSASLMechanisms(initialGreeting)).containsExactlyInAnyOrder("PLAIN", "XOAUTH2", "OAUTHBEARER"); + + client.sendCommand("CAPABILITY"); + ManageSieveClient.ServerResponse capabilityResponse = client.readResponse(); + Assertions.assertThat(getSASLMechanisms(capabilityResponse)).containsExactlyInAnyOrder("PLAIN", "XOAUTH2", "OAUTHBEARER"); + } + + private String[] getSASLMechanisms(ManageSieveClient.ServerResponse response) { + String saslLine = Assertions.assertThat(response.responseLines()) + .filteredOn(line -> line.startsWith("\"SASL\"")) + .hasSize(1) + .first() + .actual(); + return saslLine.substring("\"SASL\" \"".length(), saslLine.length() - 1).split(" "); + } +} diff --git a/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/ManageSieveClient.java b/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/ManageSieveClient.java new file mode 100644 index 00000000000..0f1e7cb9e12 --- /dev/null +++ b/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/ManageSieveClient.java @@ -0,0 +1,108 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.managesieveserver; + +import java.io.BufferedReader; +import java.io.BufferedWriter; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.OutputStreamWriter; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.Optional; + +import org.apache.commons.lang3.EnumUtils; +import org.apache.commons.net.SocketClient; +import org.apache.commons.net.io.CRLFLineReader; + +public class ManageSieveClient extends SocketClient { + private static final String ENCODING = StandardCharsets.UTF_8.name(); + + enum ResponseType { + BYE, + CONTINUATION, + NO, + OK; + } + + record ServerResponse( + ResponseType responseType, + Optional responseCode, + Optional explanation, + ArrayList responseLines + ) {} + + private BufferedReader reader; + private BufferedWriter writer; + + @Override + protected void _connectAction_() throws IOException { + super._connectAction_(); + this.reader = new CRLFLineReader(new InputStreamReader(_input_, ENCODING)); + this.writer = new BufferedWriter(new OutputStreamWriter(_output_, ENCODING)); + } + + @Override + public void disconnect() throws IOException { + super.disconnect(); + this.reader = null; + this.writer = null; + } + + public ServerResponse readResponse() throws IOException { + ServerResponse response = null; + ArrayList lines = new ArrayList<>(); + while (response == null) { + String line = this.reader.readLine(); + String[] tokens = line.split(" ", 3); + if (EnumUtils.isValidEnumIgnoreCase(ResponseType.class, tokens[0])) { + ResponseType responseType = EnumUtils.getEnumIgnoreCase(ResponseType.class, tokens[0]); + Optional responseCode = Optional.empty(); + Optional explanation = Optional.empty(); + if (tokens.length == 2 && tokens[1].startsWith("(")) { + responseCode = Optional.of(tokens[1].substring(1, tokens[1].length() - 1)); + } else if (tokens.length == 2 && !tokens[1].startsWith("(")) { + explanation = Optional.of(tokens[1]); + } else if (tokens.length == 3 && tokens[1].startsWith("(")) { + responseCode = Optional.of(tokens[1].substring(1, tokens[1].length() - 1)); + explanation = Optional.of(tokens[2]); + } else if (tokens.length == 3 && !tokens[1].startsWith("(")) { + explanation = Optional.of(tokens[1] + " " + tokens[2]); + } + if (explanation.isPresent() && explanation.get().charAt(0) == '"' && explanation.get().charAt(explanation.get().length() - 1) == '"') { + explanation = Optional.of(explanation.get().substring(1, explanation.get().length() - 1)); + } + + response = new ServerResponse(responseType, responseCode, explanation, lines); + } else if (tokens[0].equals("+")) { + Optional explanation = Optional.of(tokens[1].substring(1, tokens[1].length() - 1)); + response = new ServerResponse(ResponseType.CONTINUATION, Optional.empty(), explanation, new ArrayList()); + } else { + lines.addLast(line); + } + } + return response; + } + + public void sendCommand(String command) throws IOException { + this.writer.write(command + "\r\n"); + this.writer.flush(); + } +} diff --git a/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/ManageSieveServerTestSystem.java b/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/ManageSieveServerTestSystem.java new file mode 100644 index 00000000000..185c40e48ed --- /dev/null +++ b/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/ManageSieveServerTestSystem.java @@ -0,0 +1,93 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.managesieveserver; + +import java.net.InetAddress; + +import org.apache.commons.configuration2.HierarchicalConfiguration; +import org.apache.commons.configuration2.tree.ImmutableNode; +import org.apache.james.core.Username; +import org.apache.james.domainlist.api.DomainList; +import org.apache.james.filesystem.api.mock.MockFileSystem; +import org.apache.james.managesieve.core.CoreProcessor; +import org.apache.james.managesieve.jsieve.Parser; +import org.apache.james.managesieve.transcode.ArgumentParser; +import org.apache.james.managesieve.transcode.ManageSieveProcessor; +import org.apache.james.managesieveserver.netty.ManageSieveServer; +import org.apache.james.protocols.api.utils.ProtocolServerUtils; +import org.apache.james.server.core.configuration.FileConfigurationProvider; +import org.apache.james.sieverepository.file.SieveFileRepository; +import org.apache.james.user.memory.MemoryUsersRepository; + +class ManageSieveServerTestSystem { + private static final int MAX_LINE_LENGTH = 8000; + private static final DomainList NO_DOMAIN_LIST = null; + public static final String PASSWORD = "bobpwd"; + public static final Username USERNAME = Username.of("bob"); + + + private ManageSieveProcessor manageSieveProcessor; + public ManageSieveServer manageSieveServer; + private MemoryUsersRepository usersRepository; + private MockFileSystem fileSystem; + + public ManageSieveServerTestSystem() throws Exception { + this.usersRepository = MemoryUsersRepository.withoutVirtualHosting(NO_DOMAIN_LIST); + this.usersRepository.addUser(USERNAME, PASSWORD); + this.fileSystem = new MockFileSystem(); + this.manageSieveProcessor = new ManageSieveProcessor( + new ArgumentParser( + new CoreProcessor( + new SieveFileRepository(this.fileSystem), + this.usersRepository, + new Parser() + ) + ) + ); + } + + public void setUp(HierarchicalConfiguration configuration) throws Exception { + this.fileSystem.clear(); + this.manageSieveServer = new ManageSieveServer( + MAX_LINE_LENGTH, + this.manageSieveProcessor + ); + this.manageSieveServer.setFileSystem(this.fileSystem); + this.manageSieveServer.configure(configuration); + this.manageSieveServer.init(); + } + + public void setUp(String configFilePath) throws Exception { + HierarchicalConfiguration configuration = FileConfigurationProvider.getConfig(ClassLoader.getSystemResourceAsStream(configFilePath)); + setUp(configuration); + } + + public void setUp() throws Exception { + setUp("managesieveserver.xml"); + } + + public InetAddress getBindedIP() { + return new ProtocolServerUtils(this.manageSieveServer).retrieveBindedAddress().getAddress(); + } + + public int getBindedPort() { + return new ProtocolServerUtils(this.manageSieveServer).retrieveBindedAddress().getPort(); + } +} diff --git a/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/OIDCTest.java b/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/OIDCTest.java new file mode 100644 index 00000000000..fd2cb0b2823 --- /dev/null +++ b/server/protocols/protocols-managesieve/src/test/java/org/apache/james/managesieveserver/OIDCTest.java @@ -0,0 +1,596 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you under the Apache License, Version 2.0 (the * + * "License"); you may not use this file except in compliance * + * with the License. You may obtain a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, * + * software distributed under the License is distributed on an * + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY * + * KIND, either express or implied. See the License for the * + * specific language governing permissions and limitations * + * under the License. * + ****************************************************************/ + +package org.apache.james.managesieveserver; + +import java.nio.charset.StandardCharsets; + +import org.apache.commons.configuration2.HierarchicalConfiguration; +import org.apache.commons.configuration2.tree.ImmutableNode; +import org.apache.james.jwt.OidcTokenFixture; +import org.apache.james.protocols.api.OIDCSASLHelper; +import org.apache.james.protocols.lib.mock.ConfigLoader; +import org.apache.james.util.ClassLoaderUtils; +import org.assertj.core.api.Assertions; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestInstance; +import org.junit.jupiter.api.TestInstance.Lifecycle; +import org.mockserver.integration.ClientAndServer; +import org.mockserver.model.HttpRequest; +import org.mockserver.model.HttpResponse; + +public class OIDCTest { + private static final String DISCOVERY_URI_PATH = "/oidc/.well-known/openid-configuration"; + private static final String JWKS_URI_PATH = "/oidc/jwks"; + private static final String INTROSPECTION_URI_PATH = "/oidc/introspect"; + private static final String SCOPE = "scope"; + private static final String USERINFO_URI_PATH = "/oidc/userinfo"; + public static final String VALID_XOAUTH2_INITIAL_CLIENT_RESPONSE = OIDCSASLHelper.generateEncodedXOauth2InitialClientResponse( + OidcTokenFixture.USER_EMAIL_ADDRESS, + OidcTokenFixture.VALID_TOKEN + ); + public static final String VALID_OAUTHBEARER_INITIAL_CLIENT_RESPONSE = OIDCSASLHelper.generateEncodedXOauth2InitialClientResponse( + OidcTokenFixture.USER_EMAIL_ADDRESS, + OidcTokenFixture.VALID_TOKEN + ); + public static final String INVALID_XOAUTH2_INITIAL_CLIENT_RESPONSE = OIDCSASLHelper.generateEncodedXOauth2InitialClientResponse( + OidcTokenFixture.USER_EMAIL_ADDRESS, + OidcTokenFixture.INVALID_TOKEN + ); + public static final String INVALID_OAUTHBEARER_INITIAL_CLIENT_RESPONSE = OIDCSASLHelper.generateEncodedXOauth2InitialClientResponse( + OidcTokenFixture.USER_EMAIL_ADDRESS, + OidcTokenFixture.INVALID_TOKEN + ); + + @Nested + @TestInstance(Lifecycle.PER_CLASS) + public class LocalValidation { + private ClientAndServer authServer; + private ManageSieveClient client; + private final ManageSieveServerTestSystem testSystem; + private final HierarchicalConfiguration configuration; + + public LocalValidation() throws Exception { + this.testSystem = new ManageSieveServerTestSystem(); + this.authServer = ClientAndServer.startClientAndServer(0); + this.authServer + .when(HttpRequest.request().withPath(JWKS_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody(OidcTokenFixture.JWKS_RESPONSE, StandardCharsets.UTF_8)); + this.configuration = ConfigLoader.getConfig(ClassLoaderUtils.getSystemResourceAsSharedStream("managesieveserver.xml")); + this.configuration.addProperty("oidc.jwksURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), JWKS_URI_PATH)); + this.configuration.addProperty("oidc.claim", OidcTokenFixture.CLAIM); + this.configuration.addProperty("oidc.oidcConfigurationURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), DISCOVERY_URI_PATH)); + this.configuration.addProperty("oidc.scope", SCOPE); + } + + @BeforeAll + void initialSetup() { + System.setProperty("james.sasl.oidc.force.introspect", "false"); + System.setProperty("james.sasl.oidc.validate.aud", "false"); + } + + @BeforeEach + void setUp() throws Exception { + this.testSystem.setUp(this.configuration); + this.client = new ManageSieveClient(); + this.client.connect(testSystem.getBindedIP(), testSystem.getBindedPort()); + this.client.readResponse(); + } + + @AfterEach + void tearDown() { + this.testSystem.manageSieveServer.destroy(); + } + + @AfterAll + void finalTeardown() { + this.authServer.stop(); + System.clearProperty("james.sasl.oidc.force.introspect"); + System.clearProperty("james.sasl.oidc.validate.aud"); + } + + @Test + void oauthbearerLoginWithValidTokenShouldSucceed() throws Exception { + this.client.sendCommand("AUTHENTICATE \"OAUTHBEARER\" \"" + VALID_OAUTHBEARER_INITIAL_CLIENT_RESPONSE + "\""); + ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); + } + + @Test + void oauthbearerLoginWithValidTokenAndContinuationShouldSucceed() throws Exception { + this.client.sendCommand("AUTHENTICATE \"OAUTHBEARER\""); + ManageSieveClient.ServerResponse continuationResponse = this.client.readResponse(); + Assertions.assertThat(continuationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.CONTINUATION); + Assertions.assertThat(continuationResponse.explanation().get()).isEqualTo(""); + + this.client.sendCommand(VALID_OAUTHBEARER_INITIAL_CLIENT_RESPONSE); + ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); + } + + @Test + void oauthbearerLoginWithValidTokenAndContinuationCanBeAborted() throws Exception { + this.client.sendCommand("AUTHENTICATE \"OAUTHBEARER\""); + ManageSieveClient.ServerResponse continuationResponse = this.client.readResponse(); + Assertions.assertThat(continuationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.CONTINUATION); + Assertions.assertThat(continuationResponse.explanation().get()).isEqualTo(""); + + this.client.sendCommand("*"); + ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + Assertions.assertThat(authenticationResponse.explanation()).get().isEqualTo("Authentication failed with: authentication aborted by client"); + } + + @Test + void oauthbearerLoginWithInvalidTokenShouldNotSucceed() throws Exception { + this.client.sendCommand("AUTHENTICATE \"OAUTHBEARER\" \"" + INVALID_OAUTHBEARER_INITIAL_CLIENT_RESPONSE + "\""); + ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + } + + @Test + void xoauth2LoginWithValidTokenShouldSucceed() throws Exception { + this.client.sendCommand("AUTHENTICATE \"XOAUTH2\" \"" + VALID_XOAUTH2_INITIAL_CLIENT_RESPONSE + "\""); + ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); + } + + @Test + void xoauth2LoginWithValidTokenAndContinuationShouldSucceed() throws Exception { + this.client.sendCommand("AUTHENTICATE \"XOAUTH2\""); + ManageSieveClient.ServerResponse continuationResponse = this.client.readResponse(); + Assertions.assertThat(continuationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.CONTINUATION); + Assertions.assertThat(continuationResponse.explanation().get()).isEqualTo(""); + + this.client.sendCommand(VALID_XOAUTH2_INITIAL_CLIENT_RESPONSE); + ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); + } + + @Test + void xoauth2LoginWithValidTokenAndContinuationCanBeAborted() throws Exception { + this.client.sendCommand("AUTHENTICATE \"XOAUTH2\""); + ManageSieveClient.ServerResponse continuationResponse = this.client.readResponse(); + Assertions.assertThat(continuationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.CONTINUATION); + Assertions.assertThat(continuationResponse.explanation().get()).isEqualTo(""); + + this.client.sendCommand("*"); + ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + Assertions.assertThat(authenticationResponse.explanation()).get().isEqualTo("Authentication failed with: authentication aborted by client"); + } + + @Test + void xoauth2LoginWithInvalidTokenShouldNotSucceed() throws Exception { + this.client.sendCommand("AUTHENTICATE \"XOAUTH2\" \"" + INVALID_XOAUTH2_INITIAL_CLIENT_RESPONSE + "\""); + ManageSieveClient.ServerResponse authenticationResponse = this.client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + } + } + + @Nested + public class Introspection { + private final ManageSieveServerTestSystem testSystem; + private ClientAndServer authServer; + + public Introspection() throws Exception { + this.testSystem = new ManageSieveServerTestSystem(); + } + + @AfterEach + void tearDown() { + this.testSystem.manageSieveServer.destroy(); + this.authServer.stop(); + } + + @Test + void oauthbearerShouldSucceedWhenIntrospectReturnsActiveUser() throws Exception { + this.authServer = ClientAndServer.startClientAndServer(0); + this.authServer + .when(HttpRequest.request().withPath(INTROSPECTION_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody(String.format("{\"active\": true, \"%s\": \"%s\", \"aud\": \"%s\"}", OidcTokenFixture.CLAIM, OidcTokenFixture.USER_EMAIL_ADDRESS, OidcTokenFixture.AUDIENCE), StandardCharsets.UTF_8)); + this.authServer + .when(HttpRequest.request().withPath(JWKS_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody(OidcTokenFixture.JWKS_RESPONSE, StandardCharsets.UTF_8)); + HierarchicalConfiguration configuration = ConfigLoader.getConfig(ClassLoaderUtils.getSystemResourceAsSharedStream("managesieveserver.xml")); + configuration.addProperty("oidc.jwksURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), JWKS_URI_PATH)); + configuration.addProperty("oidc.claim", OidcTokenFixture.CLAIM); + configuration.addProperty("oidc.oidcConfigurationURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), DISCOVERY_URI_PATH)); + configuration.addProperty("oidc.scope", SCOPE); + configuration.addProperty("oidc.introspection.url", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), INTROSPECTION_URI_PATH)); + configuration.addProperty("oidc.aud", OidcTokenFixture.AUDIENCE); + testSystem.setUp(configuration); + + ManageSieveClient client = new ManageSieveClient(); + client.connect(testSystem.getBindedIP(), testSystem.getBindedPort()); + client.readResponse(); + + client.sendCommand("AUTHENTICATE \"OAUTHBEARER\" \"" + VALID_OAUTHBEARER_INITIAL_CLIENT_RESPONSE + "\""); + ManageSieveClient.ServerResponse authenticationResponse = client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); + } + + @Test + void oauthbearerShouldFailWhenIntrospectReturnsInactiveUser() throws Exception { + this.authServer = ClientAndServer.startClientAndServer(0); + this.authServer + .when(HttpRequest.request().withPath(INTROSPECTION_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody(String.format("{\"active\": false, \"%s\": \"%s\", \"aud\": \"%s\"}", OidcTokenFixture.CLAIM, OidcTokenFixture.USER_EMAIL_ADDRESS, OidcTokenFixture.AUDIENCE), StandardCharsets.UTF_8)); + this.authServer + .when(HttpRequest.request().withPath(JWKS_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody(OidcTokenFixture.JWKS_RESPONSE, StandardCharsets.UTF_8)); + HierarchicalConfiguration configuration = ConfigLoader.getConfig(ClassLoaderUtils.getSystemResourceAsSharedStream("managesieveserver.xml")); + configuration.addProperty("oidc.jwksURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), JWKS_URI_PATH)); + configuration.addProperty("oidc.claim", OidcTokenFixture.CLAIM); + configuration.addProperty("oidc.oidcConfigurationURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), DISCOVERY_URI_PATH)); + configuration.addProperty("oidc.scope", SCOPE); + configuration.addProperty("oidc.introspection.url", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), INTROSPECTION_URI_PATH)); + configuration.addProperty("oidc.aud", OidcTokenFixture.AUDIENCE); + testSystem.setUp(configuration); + + ManageSieveClient client = new ManageSieveClient(); + client.connect(testSystem.getBindedIP(), testSystem.getBindedPort()); + client.readResponse(); + + client.sendCommand("AUTHENTICATE \"OAUTHBEARER\" \"" + VALID_OAUTHBEARER_INITIAL_CLIENT_RESPONSE + "\""); + ManageSieveClient.ServerResponse authenticationResponse = client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + } + + @Test + void oauthbearerShouldFailWhenIntrospectReturnsWrongActiveUser() throws Exception { + this.authServer = ClientAndServer.startClientAndServer(0); + this.authServer + .when(HttpRequest.request().withPath(INTROSPECTION_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody(String.format("{\"active\": true, \"%s\": \"%s-wrong\", \"aud\": \"%s\"}", OidcTokenFixture.CLAIM, OidcTokenFixture.USER_EMAIL_ADDRESS, OidcTokenFixture.AUDIENCE), StandardCharsets.UTF_8)); + this.authServer + .when(HttpRequest.request().withPath(JWKS_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody(OidcTokenFixture.JWKS_RESPONSE, StandardCharsets.UTF_8)); + HierarchicalConfiguration configuration = ConfigLoader.getConfig(ClassLoaderUtils.getSystemResourceAsSharedStream("managesieveserver.xml")); + configuration.addProperty("oidc.jwksURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), JWKS_URI_PATH)); + configuration.addProperty("oidc.claim", OidcTokenFixture.CLAIM); + configuration.addProperty("oidc.oidcConfigurationURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), DISCOVERY_URI_PATH)); + configuration.addProperty("oidc.scope", SCOPE); + configuration.addProperty("oidc.introspection.url", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), INTROSPECTION_URI_PATH)); + configuration.addProperty("oidc.aud", OidcTokenFixture.AUDIENCE); + testSystem.setUp(configuration); + + ManageSieveClient client = new ManageSieveClient(); + client.connect(testSystem.getBindedIP(), testSystem.getBindedPort()); + client.readResponse(); + + client.sendCommand("AUTHENTICATE \"OAUTHBEARER\" \"" + VALID_OAUTHBEARER_INITIAL_CLIENT_RESPONSE + "\""); + ManageSieveClient.ServerResponse authenticationResponse = client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + } + + @Test + void oauthbearerShouldFailWhenIntrospectDoesNotContainActiveField() throws Exception { + this.authServer = ClientAndServer.startClientAndServer(0); + this.authServer + .when(HttpRequest.request().withPath(INTROSPECTION_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody(String.format("{\"%s\": \"%s\", \"aud\": \"%s\"}", OidcTokenFixture.CLAIM, OidcTokenFixture.USER_EMAIL_ADDRESS, OidcTokenFixture.AUDIENCE), StandardCharsets.UTF_8)); + this.authServer + .when(HttpRequest.request().withPath(JWKS_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody(OidcTokenFixture.JWKS_RESPONSE, StandardCharsets.UTF_8)); + HierarchicalConfiguration configuration = ConfigLoader.getConfig(ClassLoaderUtils.getSystemResourceAsSharedStream("managesieveserver.xml")); + configuration.addProperty("oidc.jwksURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), JWKS_URI_PATH)); + configuration.addProperty("oidc.claim", OidcTokenFixture.CLAIM); + configuration.addProperty("oidc.oidcConfigurationURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), DISCOVERY_URI_PATH)); + configuration.addProperty("oidc.scope", SCOPE); + configuration.addProperty("oidc.introspection.url", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), INTROSPECTION_URI_PATH)); + configuration.addProperty("oidc.aud", OidcTokenFixture.AUDIENCE); + testSystem.setUp(configuration); + + ManageSieveClient client = new ManageSieveClient(); + client.connect(testSystem.getBindedIP(), testSystem.getBindedPort()); + client.readResponse(); + + client.sendCommand("AUTHENTICATE \"OAUTHBEARER\" \"" + VALID_OAUTHBEARER_INITIAL_CLIENT_RESPONSE + "\""); + ManageSieveClient.ServerResponse authenticationResponse = client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + } + + @Test + void oauthbearerShouldFailWhenIntrospectDoesNotContainUserField() throws Exception { + this.authServer = ClientAndServer.startClientAndServer(0); + this.authServer + .when(HttpRequest.request().withPath(INTROSPECTION_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody(String.format("{\"active\": true, \"aud\": \"%s\"}", OidcTokenFixture.AUDIENCE), StandardCharsets.UTF_8)); + this.authServer + .when(HttpRequest.request().withPath(JWKS_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody(OidcTokenFixture.JWKS_RESPONSE, StandardCharsets.UTF_8)); + HierarchicalConfiguration configuration = ConfigLoader.getConfig(ClassLoaderUtils.getSystemResourceAsSharedStream("managesieveserver.xml")); + configuration.addProperty("oidc.jwksURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), JWKS_URI_PATH)); + configuration.addProperty("oidc.claim", OidcTokenFixture.CLAIM); + configuration.addProperty("oidc.oidcConfigurationURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), DISCOVERY_URI_PATH)); + configuration.addProperty("oidc.scope", SCOPE); + configuration.addProperty("oidc.introspection.url", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), INTROSPECTION_URI_PATH)); + configuration.addProperty("oidc.aud", OidcTokenFixture.AUDIENCE); + testSystem.setUp(configuration); + + ManageSieveClient client = new ManageSieveClient(); + client.connect(testSystem.getBindedIP(), testSystem.getBindedPort()); + client.readResponse(); + + client.sendCommand("AUTHENTICATE \"OAUTHBEARER\" \"" + VALID_OAUTHBEARER_INITIAL_CLIENT_RESPONSE + "\""); + ManageSieveClient.ServerResponse authenticationResponse = client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + } + + @Test + void oauthbearerShouldFailWhenIntrospectEndpointErrors() throws Exception { + this.authServer = ClientAndServer.startClientAndServer(0); + this.authServer + .when(HttpRequest.request().withPath(INTROSPECTION_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(500)); + this.authServer + .when(HttpRequest.request().withPath(JWKS_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody(OidcTokenFixture.JWKS_RESPONSE, StandardCharsets.UTF_8)); + HierarchicalConfiguration configuration = ConfigLoader.getConfig(ClassLoaderUtils.getSystemResourceAsSharedStream("managesieveserver.xml")); + configuration.addProperty("oidc.jwksURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), JWKS_URI_PATH)); + configuration.addProperty("oidc.claim", OidcTokenFixture.CLAIM); + configuration.addProperty("oidc.oidcConfigurationURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), DISCOVERY_URI_PATH)); + configuration.addProperty("oidc.scope", SCOPE); + configuration.addProperty("oidc.introspection.url", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), INTROSPECTION_URI_PATH)); + configuration.addProperty("oidc.aud", OidcTokenFixture.AUDIENCE); + testSystem.setUp(configuration); + + ManageSieveClient client = new ManageSieveClient(); + client.connect(testSystem.getBindedIP(), testSystem.getBindedPort()); + client.readResponse(); + + client.sendCommand("AUTHENTICATE \"OAUTHBEARER\" \"" + VALID_OAUTHBEARER_INITIAL_CLIENT_RESPONSE + "\""); + ManageSieveClient.ServerResponse authenticationResponse = client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + } + + @Test + void oauthbearerIntrospectionValidationShouldFailWhenLocalValidationFails() throws Exception { + this.authServer = ClientAndServer.startClientAndServer(0); + this.authServer + .when(HttpRequest.request().withPath(INTROSPECTION_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody(String.format("{\"active\": true, \"%s\": \"%s\", \"aud\": \"%s\"}", OidcTokenFixture.CLAIM, OidcTokenFixture.USER_EMAIL_ADDRESS, OidcTokenFixture.AUDIENCE), StandardCharsets.UTF_8)); + this.authServer + .when(HttpRequest.request().withPath(JWKS_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(500)); + HierarchicalConfiguration configuration = ConfigLoader.getConfig(ClassLoaderUtils.getSystemResourceAsSharedStream("managesieveserver.xml")); + configuration.addProperty("oidc.jwksURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), JWKS_URI_PATH)); + configuration.addProperty("oidc.claim", OidcTokenFixture.CLAIM); + configuration.addProperty("oidc.oidcConfigurationURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), DISCOVERY_URI_PATH)); + configuration.addProperty("oidc.scope", SCOPE); + configuration.addProperty("oidc.introspection.url", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), INTROSPECTION_URI_PATH)); + configuration.addProperty("oidc.aud", OidcTokenFixture.AUDIENCE); + testSystem.setUp(configuration); + + ManageSieveClient client = new ManageSieveClient(); + client.connect(testSystem.getBindedIP(), testSystem.getBindedPort()); + client.readResponse(); + + client.sendCommand("AUTHENTICATE \"OAUTHBEARER\" \"" + VALID_OAUTHBEARER_INITIAL_CLIENT_RESPONSE + "\""); + ManageSieveClient.ServerResponse authenticationResponse = client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + } + } + + @Nested + public class Userinfo { + private final ManageSieveServerTestSystem testSystem; + private ClientAndServer authServer; + + public Userinfo() throws Exception { + this.testSystem = new ManageSieveServerTestSystem(); + } + + @BeforeAll + static void initialSetup() { + System.setProperty("james.sasl.oidc.force.introspect", "false"); + System.setProperty("james.sasl.oidc.validate.aud", "false"); + } + + @AfterEach + void tearDown() { + this.testSystem.manageSieveServer.destroy(); + this.authServer.stop(); + } + + @AfterAll + static void finalTeardown() { + System.clearProperty("james.sasl.oidc.force.introspect"); + System.clearProperty("james.sasl.oidc.validate.aud"); + } + + @Test + void oauthbearerShouldSucceedWhenUserinfoClaimMatches() throws Exception { + this.authServer = ClientAndServer.startClientAndServer(0); + this.authServer + .when(HttpRequest.request().withPath(USERINFO_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody(String.format("{\"%s\": \"%s\"}", OidcTokenFixture.CLAIM, OidcTokenFixture.USER_EMAIL_ADDRESS), StandardCharsets.UTF_8)); + this.authServer + .when(HttpRequest.request().withPath(JWKS_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody(OidcTokenFixture.JWKS_RESPONSE, StandardCharsets.UTF_8)); + HierarchicalConfiguration configuration = ConfigLoader.getConfig(ClassLoaderUtils.getSystemResourceAsSharedStream("managesieveserver.xml")); + configuration.addProperty("oidc.jwksURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), JWKS_URI_PATH)); + configuration.addProperty("oidc.claim", OidcTokenFixture.CLAIM); + configuration.addProperty("oidc.oidcConfigurationURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), DISCOVERY_URI_PATH)); + configuration.addProperty("oidc.scope", SCOPE); + configuration.addProperty("oidc.userinfo.url", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), USERINFO_URI_PATH)); + testSystem.setUp(configuration); + + ManageSieveClient client = new ManageSieveClient(); + client.connect(testSystem.getBindedIP(), testSystem.getBindedPort()); + client.readResponse(); + + client.sendCommand("AUTHENTICATE \"OAUTHBEARER\" \"" + VALID_OAUTHBEARER_INITIAL_CLIENT_RESPONSE + "\""); + ManageSieveClient.ServerResponse authenticationResponse = client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.OK); + } + + @Test + void oauthbearerShouldFailWhenUserinfoClaimDiffers() throws Exception { + this.authServer = ClientAndServer.startClientAndServer(0); + this.authServer + .when(HttpRequest.request().withPath(USERINFO_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody(String.format("{\"%s\": \"test\"}", OidcTokenFixture.CLAIM), StandardCharsets.UTF_8)); + this.authServer + .when(HttpRequest.request().withPath(JWKS_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody(OidcTokenFixture.JWKS_RESPONSE, StandardCharsets.UTF_8)); + HierarchicalConfiguration configuration = ConfigLoader.getConfig(ClassLoaderUtils.getSystemResourceAsSharedStream("managesieveserver.xml")); + configuration.addProperty("oidc.jwksURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), JWKS_URI_PATH)); + configuration.addProperty("oidc.claim", OidcTokenFixture.CLAIM); + configuration.addProperty("oidc.oidcConfigurationURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), DISCOVERY_URI_PATH)); + configuration.addProperty("oidc.scope", SCOPE); + configuration.addProperty("oidc.userinfo.url", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), USERINFO_URI_PATH)); + testSystem.setUp(configuration); + + ManageSieveClient client = new ManageSieveClient(); + client.connect(testSystem.getBindedIP(), testSystem.getBindedPort()); + client.readResponse(); + + client.sendCommand("AUTHENTICATE \"OAUTHBEARER\" \"" + VALID_OAUTHBEARER_INITIAL_CLIENT_RESPONSE + "\""); + ManageSieveClient.ServerResponse authenticationResponse = client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + } + + @Test + void oauthbearerShouldFailWhenUserinfoClaimIsMissing() throws Exception { + this.authServer = ClientAndServer.startClientAndServer(0); + this.authServer + .when(HttpRequest.request().withPath(USERINFO_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody(String.format("{}"), StandardCharsets.UTF_8)); + this.authServer + .when(HttpRequest.request().withPath(JWKS_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody(OidcTokenFixture.JWKS_RESPONSE, StandardCharsets.UTF_8)); + HierarchicalConfiguration configuration = ConfigLoader.getConfig(ClassLoaderUtils.getSystemResourceAsSharedStream("managesieveserver.xml")); + configuration.addProperty("oidc.jwksURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), JWKS_URI_PATH)); + configuration.addProperty("oidc.claim", OidcTokenFixture.CLAIM); + configuration.addProperty("oidc.oidcConfigurationURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), DISCOVERY_URI_PATH)); + configuration.addProperty("oidc.scope", SCOPE); + configuration.addProperty("oidc.userinfo.url", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), USERINFO_URI_PATH)); + testSystem.setUp(configuration); + + ManageSieveClient client = new ManageSieveClient(); + client.connect(testSystem.getBindedIP(), testSystem.getBindedPort()); + client.readResponse(); + + client.sendCommand("AUTHENTICATE \"OAUTHBEARER\" \"" + VALID_OAUTHBEARER_INITIAL_CLIENT_RESPONSE + "\""); + ManageSieveClient.ServerResponse authenticationResponse = client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + } + + @Test + void oauthbearerShouldFailWhenUserinfoErrors() throws Exception { + this.authServer = ClientAndServer.startClientAndServer(0); + this.authServer + .when(HttpRequest.request().withPath(USERINFO_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(500)); + this.authServer + .when(HttpRequest.request().withPath(JWKS_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody(OidcTokenFixture.JWKS_RESPONSE, StandardCharsets.UTF_8)); + HierarchicalConfiguration configuration = ConfigLoader.getConfig(ClassLoaderUtils.getSystemResourceAsSharedStream("managesieveserver.xml")); + configuration.addProperty("oidc.jwksURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), JWKS_URI_PATH)); + configuration.addProperty("oidc.claim", OidcTokenFixture.CLAIM); + configuration.addProperty("oidc.oidcConfigurationURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), DISCOVERY_URI_PATH)); + configuration.addProperty("oidc.scope", SCOPE); + configuration.addProperty("oidc.userinfo.url", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), USERINFO_URI_PATH)); + testSystem.setUp(configuration); + + ManageSieveClient client = new ManageSieveClient(); + client.connect(testSystem.getBindedIP(), testSystem.getBindedPort()); + client.readResponse(); + + client.sendCommand("AUTHENTICATE \"OAUTHBEARER\" \"" + VALID_OAUTHBEARER_INITIAL_CLIENT_RESPONSE + "\""); + ManageSieveClient.ServerResponse authenticationResponse = client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + } + + @Test + void oauthbearerUserinfoValidationShouldFailWhenLocalValidationFails() throws Exception { + this.authServer = ClientAndServer.startClientAndServer(0); + this.authServer + .when(HttpRequest.request().withPath(USERINFO_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(200) + .withHeader("Content-Type", "application/json") + .withBody(String.format("{\"%s\": \"%s\"}", OidcTokenFixture.CLAIM, OidcTokenFixture.USER_EMAIL_ADDRESS), StandardCharsets.UTF_8)); + this.authServer + .when(HttpRequest.request().withPath(JWKS_URI_PATH)) + .respond(HttpResponse.response().withStatusCode(500)); + HierarchicalConfiguration configuration = ConfigLoader.getConfig(ClassLoaderUtils.getSystemResourceAsSharedStream("managesieveserver.xml")); + configuration.addProperty("oidc.jwksURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), JWKS_URI_PATH)); + configuration.addProperty("oidc.claim", OidcTokenFixture.CLAIM); + configuration.addProperty("oidc.oidcConfigurationURL", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), DISCOVERY_URI_PATH)); + configuration.addProperty("oidc.scope", SCOPE); + configuration.addProperty("oidc.userinfo.url", String.format("http://127.0.0.1:%s%s", this.authServer.getLocalPort(), USERINFO_URI_PATH)); + testSystem.setUp(configuration); + + ManageSieveClient client = new ManageSieveClient(); + client.connect(testSystem.getBindedIP(), testSystem.getBindedPort()); + client.readResponse(); + + client.sendCommand("AUTHENTICATE \"OAUTHBEARER\" \"" + VALID_OAUTHBEARER_INITIAL_CLIENT_RESPONSE + "\""); + ManageSieveClient.ServerResponse authenticationResponse = client.readResponse(); + Assertions.assertThat(authenticationResponse.responseType()).isEqualTo(ManageSieveClient.ResponseType.NO); + } + } +} diff --git a/server/protocols/protocols-managesieve/src/test/resources/managesieveserver-oidc.xml b/server/protocols/protocols-managesieve/src/test/resources/managesieveserver-oidc.xml new file mode 100644 index 00000000000..9ed26d01402 --- /dev/null +++ b/server/protocols/protocols-managesieve/src/test/resources/managesieveserver-oidc.xml @@ -0,0 +1,20 @@ + + managesieveserver + 0.0.0.0:4190 + + 200 + 360 + 0 + 0 + + + http://127.0.0.1/realms/test/protocol/openid-connect/certs + sub + https://127.0.0.1/realms/test/.well-known/openid-configuration + email + + https://127.0.0.1/oidc/introspect + + james + + diff --git a/server/protocols/protocols-managesieve/src/test/resources/managesieveserver.xml b/server/protocols/protocols-managesieve/src/test/resources/managesieveserver.xml new file mode 100644 index 00000000000..77cc6f3a2a7 --- /dev/null +++ b/server/protocols/protocols-managesieve/src/test/resources/managesieveserver.xml @@ -0,0 +1,9 @@ + + managesieveserver + 0.0.0.0:4190 + + 200 + 360 + 0 + 0 + diff --git a/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/SMTPSaslTest.java b/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/SMTPSaslTest.java index 0e8b5153128..45bac667831 100644 --- a/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/SMTPSaslTest.java +++ b/server/protocols/protocols-smtp/src/test/java/org/apache/james/smtpserver/SMTPSaslTest.java @@ -61,8 +61,8 @@ class SMTPSaslTest { public static final String SCOPE = "scope"; public static final String FAIL_RESPONSE_TOKEN = Base64.getEncoder().encodeToString( String.format("{\"status\":\"invalid_token\",\"scope\":\"%s\",\"schemes\":\"%s\"}", SCOPE, OIDC_URL).getBytes(UTF_8)); - public static final String VALID_TOKEN = OIDCSASLHelper.generateOauthBearer(USER.asString(), OidcTokenFixture.VALID_TOKEN); - public static final String INVALID_TOKEN = OIDCSASLHelper.generateOauthBearer(USER.asString(), OidcTokenFixture.INVALID_TOKEN); + public static final String VALID_OAUTHBEARER_TOKEN = OIDCSASLHelper.generateEncodedOauthbearerInitialClientResponse(USER.asString(), OidcTokenFixture.VALID_TOKEN); + public static final String INVALID_OAUTHBEARER_TOKEN = OIDCSASLHelper.generateEncodedOauthbearerInitialClientResponse(USER.asString(), OidcTokenFixture.INVALID_TOKEN); private final SMTPServerTestSystem testSystem = new SMTPServerTestSystem(); @@ -115,7 +115,7 @@ void tearDown() { void oauthShouldSuccessWhenValidToken() throws Exception { SMTPSClient client = initSMTPSClient(); - client.sendCommand("AUTH OAUTHBEARER " + VALID_TOKEN); + client.sendCommand("AUTH OAUTHBEARER " + VALID_OAUTHBEARER_TOKEN); assertThat(client.getReplyString()).contains("235 Authentication successful."); @@ -129,7 +129,7 @@ void oauthShouldSuccessWhenValidTokenAndContinuation() throws Exception { client.sendCommand("AUTH OAUTHBEARER"); assertThat(client.getReplyString()).contains("334"); - client.sendCommand(VALID_TOKEN); + client.sendCommand(VALID_OAUTHBEARER_TOKEN); assertThat(client.getReplyString()).contains("235 Authentication successful."); @@ -143,7 +143,7 @@ void oauthShouldSuccessWhenValidTokenAndContinuationAndXOauth2() throws Exceptio client.sendCommand("AUTH XOAUTH2"); assertThat(client.getReplyString()).contains("334"); - client.sendCommand(VALID_TOKEN); + client.sendCommand(OIDCSASLHelper.generateEncodedOauthbearerInitialClientResponse(USER.asString(), OidcTokenFixture.VALID_TOKEN)); assertThat(client.getReplyString()).contains("235 Authentication successful."); @@ -155,7 +155,7 @@ void oauthShouldSuccessWhenValidTokenAndContinuationAndXOauth2() throws Exceptio void oauthShouldSupportXOAUTH2Type() throws Exception { SMTPSClient client = initSMTPSClient(); - client.sendCommand("AUTH XOAUTH2 " + VALID_TOKEN); + client.sendCommand("AUTH XOAUTH2 " + OIDCSASLHelper.generateEncodedOauthbearerInitialClientResponse(USER.asString(), OidcTokenFixture.VALID_TOKEN)); assertThat(client.getReplyString()).contains("235 Authentication successful."); } @@ -171,7 +171,7 @@ void oauthWithNoTLSConnectShouldFail() throws Exception { .as("Should not advertise OAUTHBEARER when no TLS connect.") .doesNotContain("OAUTHBEARER"); - client.sendCommand("AUTH OAUTHBEARER " + VALID_TOKEN); + client.sendCommand("AUTH OAUTHBEARER " + VALID_OAUTHBEARER_TOKEN); assertThat(client.getReplyString()).contains("504 Unrecognized Authentication Type"); } @@ -179,7 +179,7 @@ void oauthWithNoTLSConnectShouldFail() throws Exception { void oauthShouldFailWhenInvalidToken() throws Exception { SMTPSClient client = initSMTPSClient(); - client.sendCommand("AUTH OAUTHBEARER " + INVALID_TOKEN); + client.sendCommand("AUTH OAUTHBEARER " + INVALID_OAUTHBEARER_TOKEN); assertThat(client.getReplyString()).contains("334 " + FAIL_RESPONSE_TOKEN); client.sendCommand("AQ=="); @@ -192,7 +192,7 @@ void sendMailShouldSuccessWhenAuthenticatedByOAuthBearer() throws Exception { client.sendCommand("EHLO localhost"); - client.sendCommand("AUTH OAUTHBEARER " + VALID_TOKEN); + client.sendCommand("AUTH OAUTHBEARER " + VALID_OAUTHBEARER_TOKEN); client.setSender(USER.asString()); client.addRecipient("mail@domain.org"); @@ -224,8 +224,8 @@ void sendMailShouldFailWhenNotAuthenticated() throws Exception { void shouldNotOauthWhenAlreadyAuthenticated() throws Exception { SMTPSClient client = initSMTPSClient(); - client.sendCommand("AUTH OAUTHBEARER " + VALID_TOKEN); - client.sendCommand("AUTH OAUTHBEARER " + VALID_TOKEN); + client.sendCommand("AUTH OAUTHBEARER " + VALID_OAUTHBEARER_TOKEN); + client.sendCommand("AUTH OAUTHBEARER " + VALID_OAUTHBEARER_TOKEN); assertThat(client.getReplyString()).contains("503 5.5.0 User has previously authenticated. Further authentication is not required!"); } @@ -239,7 +239,7 @@ void oauthShouldFailWhenConfigIsNotProvided() throws Exception { SMTPSClient client = initSMTPSClient(); - client.sendCommand("AUTH OAUTHBEARER " + VALID_TOKEN); + client.sendCommand("AUTH OAUTHBEARER " + VALID_OAUTHBEARER_TOKEN); assertThat(client.getReplyString()).contains("504 Unrecognized Authentication Type"); } @@ -323,7 +323,7 @@ void oauthShouldFailWhenIntrospectTokenReturnActiveIsFalse() throws Exception { SMTPSClient client = initSMTPSClient(); - client.sendCommand("AUTH OAUTHBEARER " + VALID_TOKEN); + client.sendCommand("AUTH OAUTHBEARER " + VALID_OAUTHBEARER_TOKEN); assertThat(client.getReplyString()).contains("334 " + FAIL_RESPONSE_TOKEN); @@ -352,7 +352,7 @@ void oauthShouldSuccessWhenIntrospectTokenReturnActiveIsTrue() throws Exception SMTPSClient client = initSMTPSClient(); - client.sendCommand("AUTH OAUTHBEARER " + VALID_TOKEN); + client.sendCommand("AUTH OAUTHBEARER " + VALID_OAUTHBEARER_TOKEN); assertThat(client.getReplyString()).contains("235 Authentication successful."); } @@ -377,7 +377,7 @@ void oauthShouldFailWhenIntrospectTokenServerError() throws Exception { SMTPSClient client = initSMTPSClient(); - client.sendCommand("AUTH OAUTHBEARER " + VALID_TOKEN); + client.sendCommand("AUTH OAUTHBEARER " + VALID_OAUTHBEARER_TOKEN); assertThat(client.getReplyString()).contains("451 Unable to process request"); } @@ -402,7 +402,7 @@ void oauthShouldSuccessWhenCheckTokenByUserInfoIsPassed() throws Exception { SMTPSClient client = initSMTPSClient(); - client.sendCommand("AUTH OAUTHBEARER " + VALID_TOKEN); + client.sendCommand("AUTH OAUTHBEARER " + VALID_OAUTHBEARER_TOKEN); assertThat(client.getReplyString()).contains("235 Authentication successful."); } @@ -426,7 +426,7 @@ void oauthShouldFailWhenCheckTokenByUserInfoIsFailed() throws Exception { SMTPSClient client = initSMTPSClient(); - client.sendCommand("AUTH OAUTHBEARER " + VALID_TOKEN); + client.sendCommand("AUTH OAUTHBEARER " + VALID_OAUTHBEARER_TOKEN); assertThat(client.getReplyString()).contains("451 Unable to process request"); } @@ -434,7 +434,7 @@ void oauthShouldFailWhenCheckTokenByUserInfoIsFailed() throws Exception { @Test void oauthShouldImpersonateFailWhenNOTDelegated() throws Exception { SMTPSClient client = initSMTPSClient(); - String tokenWithImpersonation = OIDCSASLHelper.generateOauthBearer("another@domain.org", OidcTokenFixture.VALID_TOKEN); + String tokenWithImpersonation = OIDCSASLHelper.generateEncodedOauthbearerInitialClientResponse("another@domain.org", OidcTokenFixture.VALID_TOKEN); client.sendCommand("AUTH OAUTHBEARER " + tokenWithImpersonation); assertThat(client.getReplyString()).contains("334 "); @@ -446,7 +446,7 @@ void oauthShouldImpersonateFailWhenNOTDelegated() throws Exception { @Test void oauthShouldImpersonateSuccessWhenDelegated() throws Exception { SMTPSClient client = initSMTPSClient(); - String tokenWithImpersonation = OIDCSASLHelper.generateOauthBearer(USER2.asString(), OidcTokenFixture.VALID_TOKEN); + String tokenWithImpersonation = OIDCSASLHelper.generateEncodedOauthbearerInitialClientResponse(USER2.asString(), OidcTokenFixture.VALID_TOKEN); client.sendCommand("AUTH OAUTHBEARER " + tokenWithImpersonation); assertThat(client.getReplyString()).contains("235 Authentication successful."); @@ -458,7 +458,7 @@ void impersonationShouldWorkWhenDelegated() throws Exception { client.sendCommand("EHLO localhost"); - client.sendCommand("AUTH OAUTHBEARER " + OIDCSASLHelper.generateOauthBearer(USER2.asString(), OidcTokenFixture.VALID_TOKEN)); + client.sendCommand("AUTH OAUTHBEARER " + OIDCSASLHelper.generateEncodedOauthbearerInitialClientResponse(USER2.asString(), OidcTokenFixture.VALID_TOKEN)); client.setSender(USER2.asString()); client.addRecipient("mail@domain.org");