Use Ktor for Nextcloud Login Flow (#1817)

* [WIP] Use Ktor for Nextcloud login flow

- Replace OkHttp with Ktor for HTTP requests
- Update URL handling to use Ktor's `Url` class
- Adjust `postForJson` method to use Ktor's HTTP client
- Refactor URL building logic for login flow initiation

* Use Ktor for Nextcloud login flow

- Migrate to Ktor's ContentNegotiation plugin for JSON handling
- Update dependencies and configuration for Ktor serialization
- Refactor `NextcloudLoginFlow` to use Ktor's JSON serialization

* Add tests

* Allow unit tests that mock/use HttpClient without Conscrypt

* KDoc

* Minor fixes

* Use toUrlOrNull from dav4jvm

* Don't change strings in this PR

* Update dav4jvm and synctools
This commit is contained in:
Ricki Hirner
2025-12-08 15:40:39 +01:00
committed by GitHub
parent 9eb70a5564
commit 0d4f154baf
7 changed files with 185 additions and 124 deletions

View File

@@ -7,8 +7,8 @@ plugins {
alias(libs.plugins.compose.compiler)
alias(libs.plugins.hilt)
alias(libs.plugins.kotlin.android)
alias(libs.plugins.kotlin.serialization)
alias(libs.plugins.ksp)
alias(libs.plugins.mikepenz.aboutLibraries.android)
}
@@ -190,8 +190,10 @@ dependencies {
implementation(libs.conscrypt)
implementation(libs.dnsjava)
implementation(libs.guava)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.okhttp)
implementation(libs.ktor.serialization.kotlinx.json)
implementation(libs.mikepenz.aboutLibraries.m3)
implementation(libs.okhttp.base)
implementation(libs.okhttp.brotli)

View File

@@ -29,8 +29,7 @@ class ConscryptIntegration {
if (initialized)
return
val alreadyInstalled = conscryptInstalled()
if (!alreadyInstalled) {
if (Conscrypt.isAvailable() && !conscryptInstalled()) {
// install Conscrypt as most preferred provider
Security.insertProviderAt(Conscrypt.newProvider(), 1)

View File

@@ -23,6 +23,8 @@ import com.google.errorprone.annotations.MustBeClosed
import dagger.hilt.android.qualifiers.ApplicationContext
import io.ktor.client.HttpClient
import io.ktor.client.engine.okhttp.OkHttp
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.serialization.kotlinx.json.json
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
import net.openid.appauth.AuthState
@@ -391,6 +393,11 @@ class HttpClientBuilder @Inject constructor(
val client = HttpClient(OkHttp) {
// Ktor-level configuration here
// automatically convert JSON from/into data classes (if requested in respective code)
install(ContentNegotiation) {
json()
}
engine {
// okhttp engine configuration here

View File

@@ -4,25 +4,22 @@
package at.bitfire.davdroid.network
import at.bitfire.dav4jvm.okhttp.exception.DavException
import at.bitfire.dav4jvm.okhttp.exception.HttpException
import androidx.annotation.VisibleForTesting
import at.bitfire.davdroid.settings.Credentials
import at.bitfire.davdroid.ui.setup.LoginInfo
import at.bitfire.davdroid.util.SensitiveString.Companion.toSensitiveString
import at.bitfire.davdroid.util.withTrailingSlash
import at.bitfire.vcard4android.GroupMethod
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONObject
import java.net.HttpURLConnection
import io.ktor.client.call.body
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.URLBuilder
import io.ktor.http.Url
import io.ktor.http.appendPathSegments
import io.ktor.http.contentType
import io.ktor.http.path
import kotlinx.serialization.Serializable
import java.net.URI
import javax.inject.Inject
@@ -35,6 +32,113 @@ class NextcloudLoginFlow @Inject constructor(
httpClientBuilder: HttpClientBuilder
) {
private val httpClient = httpClientBuilder.buildKtor()
// Login flow state
var pollUrl: Url? = null
var token: String? = null
/**
* Starts Nextcloud Login Flow v2.
*
* @param baseUrl Nextcloud login flow or base URL
*
* @return URL that should be opened in the browser (login screen)
*/
suspend fun start(baseUrl: Url): Url {
// reset fields in case something goes wrong
pollUrl = null
token = null
// POST to login flow URL in order to receive endpoint data
val result = httpClient.post(loginFlowUrl(baseUrl))
val endpointData: EndpointData = result.body()
// save endpoint data for polling
pollUrl = Url(endpointData.poll.endpoint)
token = endpointData.poll.token
return Url(endpointData.login)
}
@VisibleForTesting
internal fun loginFlowUrl(baseUrl: Url): Url {
return when {
// already a Login Flow v2 URL
baseUrl.encodedPath.endsWith(FLOW_V2_PATH) ->
baseUrl
// Login Flow v1 URL, rewrite to v2
baseUrl.encodedPath.endsWith(FLOW_V1_PATH) -> {
// drop "[index.php/login]/flow" from the end and append "/v2"
val v2Segments = baseUrl.segments.dropLast(1) + "v2"
val builder = URLBuilder(baseUrl)
builder.path(*v2Segments.toTypedArray())
builder.build()
}
// other URL, make it a Login Flow v2 URL
else ->
URLBuilder(baseUrl)
.appendPathSegments(FLOW_V2_PATH.split('/'))
.build()
}
}
/**
* Retrieves login info from the polling endpoint using [pollUrl]/[token].
*/
suspend fun fetchLoginInfo(): LoginInfo {
val pollUrl = pollUrl ?: throw IllegalArgumentException("Missing pollUrl")
val token = token ?: throw IllegalArgumentException("Missing token")
// send HTTP request to request server, login name and app password
val result = httpClient.post(pollUrl) {
contentType(ContentType.Application.FormUrlEncoded)
setBody("token=$token")
}
val loginData: LoginData = result.body()
// make sure server URL ends with a slash so that DAV_PATH can be appended
val serverUrl = loginData.server.withTrailingSlash()
return LoginInfo(
baseUri = URI(serverUrl).resolve(DAV_PATH),
credentials = Credentials(
username = loginData.loginName,
password = loginData.appPassword.toSensitiveString()
),
suggestedGroupMethod = GroupMethod.CATEGORIES
)
}
/**
* Represents the JSON response that is returned on the first call to `/login/v2`.
*/
@Serializable
private data class EndpointData(
val poll: Poll,
val login: String
) {
@Serializable
data class Poll(
val token: String,
val endpoint: String
)
}
/**
* Represents the JSON response that is returned by the polling endpoint.
*/
@Serializable
private data class LoginData(
val server: String,
val loginName: String,
val appPassword: String
)
companion object {
const val FLOW_V1_PATH = "index.php/login/flow"
const val FLOW_V2_PATH = "index.php/login/v2"
@@ -43,92 +147,4 @@ class NextcloudLoginFlow @Inject constructor(
const val DAV_PATH = "remote.php/dav"
}
val httpClient = httpClientBuilder.build()
// Login flow state
var loginUrl: HttpUrl? = null
var pollUrl: HttpUrl? = null
var token: String? = null
suspend fun initiate(baseUrl: HttpUrl): HttpUrl? {
loginUrl = null
pollUrl = null
token = null
val json = postForJson(initiateUrl(baseUrl), "".toRequestBody())
loginUrl = json.getString("login").toHttpUrlOrNull()
json.getJSONObject("poll").let { poll ->
pollUrl = poll.getString("endpoint").toHttpUrl()
token = poll.getString("token")
}
return loginUrl
}
fun initiateUrl(baseUrl: HttpUrl): HttpUrl {
val path = baseUrl.encodedPath
if (path.endsWith(FLOW_V2_PATH))
// already a Login Flow v2 URL
return baseUrl
if (path.endsWith(FLOW_V1_PATH))
// Login Flow v1 URL, rewrite to v2
return baseUrl.newBuilder()
.encodedPath(path.replace(FLOW_V1_PATH, FLOW_V2_PATH))
.build()
// other URL, make it a Login Flow v2 URL
return baseUrl.newBuilder()
.addPathSegments(FLOW_V2_PATH)
.build()
}
suspend fun fetchLoginInfo(): LoginInfo {
val pollUrl = pollUrl ?: throw IllegalArgumentException("Missing pollUrl")
val token = token ?: throw IllegalArgumentException("Missing token")
// send HTTP request to request server, login name and app password
val json = postForJson(pollUrl, "token=$token".toRequestBody("application/x-www-form-urlencoded".toMediaType()))
// make sure server URL ends with a slash so that DAV_PATH can be appended
val serverUrl = json.getString("server").withTrailingSlash()
return LoginInfo(
baseUri = URI(serverUrl).resolve(DAV_PATH),
credentials = Credentials(
username = json.getString("loginName"),
password = json.getString("appPassword").toSensitiveString()
),
suggestedGroupMethod = GroupMethod.CATEGORIES
)
}
private suspend fun postForJson(url: HttpUrl, requestBody: RequestBody): JSONObject = withContext(Dispatchers.IO) {
val postRq = Request.Builder()
.url(url)
.post(requestBody)
.build()
val response = runInterruptible {
httpClient.newCall(postRq).execute()
}
if (response.code != HttpURLConnection.HTTP_OK)
throw HttpException(response)
response.body.use { body ->
val mimeType = body.contentType() ?: throw DavException("Login Flow response without MIME type")
if (mimeType.type != "application" || mimeType.subtype != "json")
throw DavException("Invalid Login Flow response (not JSON)")
// decode JSON
return@withContext JSONObject(body.string())
}
}
}

View File

@@ -10,15 +10,15 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import at.bitfire.dav4jvm.ktor.toUrlOrNull
import at.bitfire.davdroid.network.NextcloudLoginFlow
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
import dagger.hilt.android.lifecycle.HiltViewModel
import dagger.hilt.android.qualifiers.ApplicationContext
import io.ktor.http.Url
import kotlinx.coroutines.launch
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
import java.util.logging.Level
import java.util.logging.Logger
@@ -46,24 +46,19 @@ class NextcloudLoginModel @AssistedInject constructor(
val error: String? = null,
/** URL to open in the browser (set during Login Flow) */
val loginUrl: HttpUrl? = null,
val loginUrl: Url? = null,
/** login info (set after successful login) */
val result: LoginInfo? = null
) {
val baseUrlWithPrefix =
if (baseUrl.startsWith("http://") || baseUrl.startsWith("https://"))
baseUrl
else
"https://$baseUrl"
val baseKtorUrl = baseUrlWithPrefix.toUrlOrNull()
val baseHttpUrl: HttpUrl? = run {
val baseUrlWithPrefix =
if (baseUrl.startsWith("http://") || baseUrl.startsWith("https://"))
baseUrl
else
"https://$baseUrl"
baseUrlWithPrefix.toHttpUrlOrNull()
}
val canContinue = !inProgress && baseHttpUrl != null
val canContinue = !inProgress && baseKtorUrl != null
}
var uiState by mutableStateOf(UiState())
@@ -107,7 +102,7 @@ class NextcloudLoginModel @AssistedInject constructor(
* Starts the Login Flow.
*/
fun startLoginFlow() {
val baseUrl = uiState.baseHttpUrl
val baseUrl = uiState.baseKtorUrl
if (uiState.inProgress || baseUrl == null)
return
@@ -118,13 +113,12 @@ class NextcloudLoginModel @AssistedInject constructor(
viewModelScope.launch {
try {
val loginUrl = loginFlow.initiate(baseUrl)
val loginUrl = loginFlow.start(baseUrl)
uiState = uiState.copy(
loginUrl = loginUrl,
inProgress = false
)
} catch (e: Exception) {
logger.log(Level.WARNING, "Initiating Login Flow failed", e)

View File

@@ -0,0 +1,40 @@
/*
* Copyright © All Contributors. See LICENSE and AUTHORS in the root directory for details.
*/
package at.bitfire.davdroid.network
import io.ktor.http.Url
import io.mockk.mockk
import org.junit.Assert.assertEquals
import org.junit.Test
class NextcloudLoginFlowTest {
private val flow = NextcloudLoginFlow(mockk(relaxed = true))
@Test
fun `loginFlowUrl accepts v2 URL`() {
assertEquals(
Url("http://example.com/index.php/login/v2"),
flow.loginFlowUrl(Url("http://example.com/index.php/login/v2"))
)
}
@Test
fun `loginFlowUrl rewrites root URL to v2 URL`() {
assertEquals(
Url("http://example.com/index.php/login/v2"),
flow.loginFlowUrl(Url("http://example.com/"))
)
}
@Test
fun `loginFlowUrl rewrites v1 URL to v2 URL`() {
assertEquals(
Url("http://example.com/index.php/login/v2"),
flow.loginFlowUrl(Url("http://example.com/index.php/login/flow"))
)
}
}

View File

@@ -19,7 +19,7 @@ androidx-test-rules = "1.7.0"
androidx-test-junit = "1.3.0"
androidx-work = "2.11.0"
bitfire-cert4android = "42d883e958"
bitfire-dav4jvm = "acd9bca096"
bitfire-dav4jvm = "de16b12343"
bitfire-synctools = "5fb54ec88c"
compose-accompanist = "0.37.3"
compose-bom = "2025.11.01"
@@ -95,8 +95,10 @@ junit = { module = "junit:junit", version = "4.13.2" }
kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" }
kotlinx-coroutines = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
mikepenz-aboutLibraries-m3 = { module = "com.mikepenz:aboutlibraries-compose-m3", version.ref = "mikepenz-aboutLibraries" }
mockk = { module = "io.mockk:mockk", version.ref = "mockk" }
mockk-android = { module = "io.mockk:mockk-android", version.ref = "mockk" }
@@ -119,5 +121,6 @@ android-application = { id = "com.android.application", version.ref = "android-a
compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
hilt = { id = "com.google.dagger.hilt.android", version.ref = "hilt" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
mikepenz-aboutLibraries-android = { id = "com.mikepenz.aboutlibraries.plugin.android", version.ref = "mikepenz-aboutLibraries" }