diff --git a/core/api/core.api b/core/api/core.api index 7606689d07..a99f22e525 100644 --- a/core/api/core.api +++ b/core/api/core.api @@ -23584,6 +23584,7 @@ public final class org/hisp/dhis/android/core/user/oauth2/OAuth2Config$Companion public abstract interface class org/hisp/dhis/android/core/user/oauth2/OAuth2Handler { public abstract fun blockingBuildEnrollmentUrl (Ljava/lang/String;)Ljava/lang/String; + public abstract fun blockingBuildLogoutUrl (Ljava/lang/String;)Ljava/lang/String; public fun blockingChangePin (Ljava/lang/String;Ljava/lang/String;)Lorg/hisp/dhis/android/core/arch/helpers/Result; public abstract fun blockingHandleEnrollmentResponse (Ljava/lang/String;Ljava/lang/String;)V public abstract fun blockingHandleLogInResponse (Ljava/lang/String;Ljava/lang/String;)Lorg/hisp/dhis/android/core/user/User; diff --git a/core/src/main/java/org/hisp/dhis/android/core/user/oauth2/OAuth2Handler.kt b/core/src/main/java/org/hisp/dhis/android/core/user/oauth2/OAuth2Handler.kt index e7e6871ba0..a01c4dc1f6 100644 --- a/core/src/main/java/org/hisp/dhis/android/core/user/oauth2/OAuth2Handler.kt +++ b/core/src/main/java/org/hisp/dhis/android/core/user/oauth2/OAuth2Handler.kt @@ -40,6 +40,8 @@ interface OAuth2Handler { fun blockingHandleEnrollmentResponse(serverUrl: String, iat: String) + fun blockingBuildLogoutUrl(serverUrl: String): String + fun blockingLogIn(config: OAuth2Config): String fun blockingHandleLogInResponse(serverUrl: String, authorizationCode: String): User diff --git a/core/src/main/java/org/hisp/dhis/android/core/user/oauth2/internal/OAuth2HandlerImpl.kt b/core/src/main/java/org/hisp/dhis/android/core/user/oauth2/internal/OAuth2HandlerImpl.kt index aa912ca286..e806f20832 100644 --- a/core/src/main/java/org/hisp/dhis/android/core/user/oauth2/internal/OAuth2HandlerImpl.kt +++ b/core/src/main/java/org/hisp/dhis/android/core/user/oauth2/internal/OAuth2HandlerImpl.kt @@ -109,6 +109,16 @@ internal class OAuth2HandlerImpl( runBlocking { handleEnrollmentResponseInternal(serverUrl, iat) } } + override fun blockingBuildLogoutUrl(serverUrl: String): String { + return runBlocking { buildLogoutUrlInternal(serverUrl) } + } + + private fun buildLogoutUrlInternal(serverUrl: String): String { + val state = JWTHelper.generateState() + oauth2SecureStore.tempState = state + return oauth2NetworkHandler.buildLogoutUrl(serverUrl, state) + } + private suspend fun logInInternal(config: OAuth2Config): String { check(isDeviceRegistered()) { "Device not registered. Call handleEnrollmentResponse first." } diff --git a/core/src/main/java/org/hisp/dhis/android/core/user/oauth2/internal/OAuth2NetworkHandler.kt b/core/src/main/java/org/hisp/dhis/android/core/user/oauth2/internal/OAuth2NetworkHandler.kt index af9d924e2c..004d1abadf 100644 --- a/core/src/main/java/org/hisp/dhis/android/core/user/oauth2/internal/OAuth2NetworkHandler.kt +++ b/core/src/main/java/org/hisp/dhis/android/core/user/oauth2/internal/OAuth2NetworkHandler.kt @@ -58,4 +58,9 @@ internal interface OAuth2NetworkHandler { keyId: String, clientAssertion: String, ): Result + + fun buildLogoutUrl( + serverUrl: String, + state: String, + ): String } diff --git a/core/src/main/java/org/hisp/dhis/android/network/oauth2/OAuth2NetworkHandlerImpl.kt b/core/src/main/java/org/hisp/dhis/android/network/oauth2/OAuth2NetworkHandlerImpl.kt index 4be74f13ae..568d5ac63f 100644 --- a/core/src/main/java/org/hisp/dhis/android/network/oauth2/OAuth2NetworkHandlerImpl.kt +++ b/core/src/main/java/org/hisp/dhis/android/network/oauth2/OAuth2NetworkHandlerImpl.kt @@ -132,4 +132,10 @@ internal class OAuth2NetworkHandlerImpl( ) } } + + override fun buildLogoutUrl(serverUrl: String, state: String): String { + return "$serverUrl/dhis-web-commons-security/logout.action" + + "?redirect_uri=${OAuth2Config.DEFAULT_REDIRECT_URI}" + + "&state=$state" + } } diff --git a/core/src/test/java/org/hisp/dhis/android/core/user/oauth2/internal/OAuth2HandlerImplShould.kt b/core/src/test/java/org/hisp/dhis/android/core/user/oauth2/internal/OAuth2HandlerImplShould.kt index 9de6f7676d..5d6b10b7cd 100644 --- a/core/src/test/java/org/hisp/dhis/android/core/user/oauth2/internal/OAuth2HandlerImplShould.kt +++ b/core/src/test/java/org/hisp/dhis/android/core/user/oauth2/internal/OAuth2HandlerImplShould.kt @@ -166,6 +166,31 @@ class OAuth2HandlerImplShould { // endregion + // region blockingBuildLogoutUrl + + @Test + fun blockingBuildLogoutUrl_persists_temp_state_and_returns_url() { + whenever(oauth2NetworkHandler.buildLogoutUrl(any(), any())).thenReturn(LOGOUT_URL) + + val result = handler.blockingBuildLogoutUrl(NORMALIZED_URL) + + assertThat(result).isEqualTo(LOGOUT_URL) + assertThat(oauth2SecureStore.tempState).isNotNull() + verify(oauth2NetworkHandler).buildLogoutUrl(eq(NORMALIZED_URL), eq(oauth2SecureStore.tempState!!)) + } + + @Test + fun blockingBuildLogoutUrl_delegates_raw_server_url_without_normalizing() { + whenever(oauth2NetworkHandler.buildLogoutUrl(any(), any())).thenReturn(LOGOUT_URL) + + handler.blockingBuildLogoutUrl("HTTPS://Server.com/") + + // Unlike enrollment, the logout url is built from the raw server url (no normalization). + verify(oauth2NetworkHandler).buildLogoutUrl(eq("HTTPS://Server.com/"), any()) + } + + // endregion + // region blockingHandleLogInResponse @Test(expected = IllegalStateException::class) @@ -527,6 +552,7 @@ class OAuth2HandlerImplShould { companion object { private const val ENROLL_URL = "https://server.com/oauth2/dcr/enroll" + private const val LOGOUT_URL = "https://server.com/dhis-web-commons-security/logout.action" private const val NORMALIZED_URL = "https://server.com" private const val CLIENT_ID = "client-1" private const val KEY_ID = "key-1" diff --git a/core/src/test/java/org/hisp/dhis/android/network/oauth2/OAuth2NetworkHandlerImplShould.kt b/core/src/test/java/org/hisp/dhis/android/network/oauth2/OAuth2NetworkHandlerImplShould.kt new file mode 100644 index 0000000000..8b4594fb3a --- /dev/null +++ b/core/src/test/java/org/hisp/dhis/android/network/oauth2/OAuth2NetworkHandlerImplShould.kt @@ -0,0 +1,96 @@ +/* + * Copyright (c) 2004-2026, University of Oslo + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * Neither the name of the HISP project nor the names of its contributors may + * be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.hisp.dhis.android.network.oauth2 + +import com.google.common.truth.Truth.assertThat +import org.hisp.dhis.android.core.arch.api.HttpServiceClient +import org.hisp.dhis.android.core.arch.api.executors.internal.CoroutineAPICallExecutor +import org.hisp.dhis.android.core.arch.storage.internal.InMemorySecureStore +import org.hisp.dhis.android.core.user.oauth2.OAuth2Config +import org.hisp.dhis.android.core.user.oauth2.internal.OAuth2SecureStore +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.junit.runners.JUnit4 +import org.mockito.kotlin.mock + +@RunWith(JUnit4::class) +class OAuth2NetworkHandlerImplShould { + + private val httpClient: HttpServiceClient = mock() + private val coroutineAPICallExecutor: CoroutineAPICallExecutor = mock() + private val oAuth2SecureStore = OAuth2SecureStore(InMemorySecureStore()) + + private lateinit var networkHandler: OAuth2NetworkHandlerImpl + + @Before + fun setUp() { + networkHandler = OAuth2NetworkHandlerImpl(httpClient, coroutineAPICallExecutor, oAuth2SecureStore) + } + + // region buildLogoutUrl + + @Test + fun buildLogoutUrl_builds_logout_action_url_with_default_redirect_uri_and_state() { + val url = networkHandler.buildLogoutUrl(SERVER_URL, STATE) + + assertThat(url).isEqualTo( + "$SERVER_URL/dhis-web-commons-security/logout.action" + + "?redirect_uri=${OAuth2Config.DEFAULT_REDIRECT_URI}" + + "&state=$STATE", + ) + } + + @Test + fun buildLogoutUrl_appends_path_to_the_provided_server_url_verbatim() { + val url = networkHandler.buildLogoutUrl("https://play.dhis2.org/40", STATE) + + assertThat(url).isEqualTo( + "https://play.dhis2.org/40/dhis-web-commons-security/logout.action" + + "?redirect_uri=${OAuth2Config.DEFAULT_REDIRECT_URI}" + + "&state=$STATE", + ) + } + + @Test + fun buildLogoutUrl_includes_the_provided_state_as_a_query_parameter() { + val withState = networkHandler.buildLogoutUrl(SERVER_URL, "state-a") + val withOtherState = networkHandler.buildLogoutUrl(SERVER_URL, "state-b") + + assertThat(withState).endsWith("&state=state-a") + assertThat(withOtherState).endsWith("&state=state-b") + assertThat(withState).isNotEqualTo(withOtherState) + } + + // endregion + + companion object { + private const val SERVER_URL = "https://server.com" + private const val STATE = "state-1" + } +}