sync: what the on-device round found
Everything here came from running the account flow against a real Nextcloud rather than from reading the code. Discovery - A typed bare origin now gets the RFC 6764 well-known probe. It was returned as the only candidate, so `https://cloud.example.com` — what people actually type — was PROPFIND'd against the web UI, answered 405, and a working Nextcloud reported as "not a CalDAV server". - A same-host HTTPS→HTTP redirect is put back on TLS instead of refused (`dav` change 7). A Nextcloud behind a TLS-terminating proxy without `overwriteprotocol` builds every redirect with http://, including the /.well-known/caldav hop discovery depends on. Cross-host still throws. - Outcomes carry a `Cause` the UI translates, not the server's own words. "HTTP 405 Method Not Allowed" told someone entering an address nothing, in a language they may not read, from outside strings.xml. - An IPv6 origin keeps its brackets: `HttpUrl.host` returns "fd00::1", so the rebuilt origin did not parse and a homelab address came back as "not an address". Login Flow v2 - The poll response's scheme is coerced, never refused. Nextcloud returns the app password exactly once, so throwing there burned a live credential and left it dangling in the user's device list. The host mismatch already worked this way; the scheme now matches it. Accounts - The accounts screen observes Room and the sign-in state instead of taking a snapshot, so a sync landing — or a 401 stopping an account — reaches a screen that is already open. - A per-account detail screen, and provider identity (`CalDavProvider`) shared with the quirk table so one list drives both the icon and the warning. - The password field masks: floret-kit's `InlineTextField` gained a visual transformation, since `KeyboardType.Password` only tells the IME to drop suggestions.
This commit is contained in:
@@ -3,10 +3,12 @@ package de.jeanlucmakiola.caldav
|
||||
import at.bitfire.dav4jvm.DavResource
|
||||
import at.bitfire.dav4jvm.Response
|
||||
import at.bitfire.dav4jvm.property.CalendarHomeSet
|
||||
import at.bitfire.dav4jvm.exception.HttpException
|
||||
import at.bitfire.dav4jvm.property.CurrentUserPrincipal
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import java.io.IOException
|
||||
|
||||
/**
|
||||
* RFC 6764 discovery: from what the user typed to the list of task collections.
|
||||
@@ -75,9 +77,61 @@ class CalDavDiscovery(
|
||||
/** A 200 whose body says the credentials were not accepted (RFC 5397 §3). */
|
||||
data object Unauthenticated : Outcome
|
||||
|
||||
data class NotCalDav(val reason: String) : Outcome
|
||||
data class NotCalDav(val cause: Cause, val detail: String) : Outcome
|
||||
|
||||
data class Failed(val reason: String) : Outcome
|
||||
data class Failed(val cause: Cause, val detail: String) : Outcome
|
||||
|
||||
/**
|
||||
* Why discovery ended, in a form the UI can translate.
|
||||
*
|
||||
* ⚠️ The UI must render *this*, never [Failed.detail]. A server's own
|
||||
* words are untranslatable, frequently in a language the user does not
|
||||
* read, and quite often a bare status line — "HTTP 405 Method Not
|
||||
* Allowed" tells someone entering their address precisely nothing, and
|
||||
* bypasses `strings.xml` entirely. [detail] exists for logs and bug
|
||||
* reports, and is never shown.
|
||||
*/
|
||||
enum class Cause {
|
||||
/** The address is not a URL, or names nothing we can look up. */
|
||||
NOT_AN_ADDRESS,
|
||||
|
||||
/** Reached something, but it does not speak WebDAV at all. */
|
||||
NOT_A_DAV_SERVER,
|
||||
|
||||
/** Speaks WebDAV but not CalDAV — a file-sharing endpoint, say. */
|
||||
NO_CALENDAR_SUPPORT,
|
||||
|
||||
/** Nothing answered: DNS, connection refused, TLS, timeout. */
|
||||
UNREACHABLE,
|
||||
|
||||
/** The address, or where it redirects, is plain HTTP. */
|
||||
INSECURE,
|
||||
|
||||
/** Signed in, but the account exposes no calendar home. */
|
||||
NO_CALENDARS,
|
||||
|
||||
/** The server answered, and the answer was an error of its own. */
|
||||
SERVER_ERROR,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Classifies a transport or protocol failure for the UI.
|
||||
*
|
||||
* ⚠️ **405 is the interesting one.** It is what an ordinary web server
|
||||
* answers to `PROPFIND`, which makes it the single most likely response to
|
||||
* someone typing their *website* instead of their CalDAV address — and it
|
||||
* means exactly "this is not a DAV server". Surfacing it as "HTTP 405 Method
|
||||
* Not Allowed" hands the user a status code where they needed a sentence.
|
||||
*/
|
||||
private fun causeOf(error: Throwable): Outcome.Cause = when {
|
||||
error is HttpException -> when (error.code) {
|
||||
METHOD_NOT_ALLOWED, NOT_IMPLEMENTED, NOT_FOUND -> Outcome.Cause.NOT_A_DAV_SERVER
|
||||
else -> Outcome.Cause.SERVER_ERROR
|
||||
}
|
||||
// Everything that never got an answer: DNS, refused, TLS, timeout.
|
||||
error is IOException -> Outcome.Cause.UNREACHABLE
|
||||
else -> Outcome.Cause.SERVER_ERROR
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -91,16 +145,18 @@ class CalDavDiscovery(
|
||||
ServiceDiscovery.asBaseUrl(input)?.let { typed ->
|
||||
if (!typed.isHttps && !allowCleartext) {
|
||||
return Outcome.Failed(
|
||||
"\"$input\" is an unencrypted http:// address. Credentials are never sent " +
|
||||
"over cleartext, so this can only ever answer \"not authorised\".",
|
||||
Outcome.Cause.INSECURE,
|
||||
"\"$input\" is an unencrypted http:// address",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
val candidates = ServiceDiscovery.candidatesFor(input, dns)
|
||||
if (candidates.isEmpty()) return Outcome.Failed("could not read \"$input\" as an address or URL")
|
||||
if (candidates.isEmpty()) {
|
||||
return Outcome.Failed(Outcome.Cause.NOT_AN_ADDRESS, "no candidates for \"$input\"")
|
||||
}
|
||||
|
||||
var lastFailure: Outcome = Outcome.Failed("no candidate answered")
|
||||
var lastFailure: Outcome = Outcome.Failed(Outcome.Cause.UNREACHABLE, "no candidate answered")
|
||||
for (candidate in candidates) {
|
||||
when (val outcome = probe(candidate.url)) {
|
||||
is Outcome.Found, is Outcome.NeedsAuthentication, Outcome.Unauthenticated -> return outcome
|
||||
@@ -135,7 +191,7 @@ class CalDavDiscovery(
|
||||
// 401 is not a failure — iCloud and Zoho answer it from
|
||||
// /.well-known/caldav, which *is* the DAV root, and it is RFC-legal.
|
||||
if (isUnauthorized(error)) return Outcome.NeedsAuthentication(listOf(base.host))
|
||||
return Outcome.Failed(error.message ?: error.toString())
|
||||
return Outcome.Failed(causeOf(error), error.message ?: error.toString())
|
||||
}
|
||||
|
||||
// ⚠️ RFC 5397 §3: a 200 carrying <D:unauthenticated/> means the
|
||||
@@ -148,17 +204,29 @@ class CalDavDiscovery(
|
||||
|
||||
val href = principalHref
|
||||
?: return if (davCapabilities.contains("calendar-access")) {
|
||||
Outcome.Failed("server advertises calendar-access but returned no principal")
|
||||
Outcome.Failed(
|
||||
Outcome.Cause.NO_CALENDAR_SUPPORT,
|
||||
"advertises calendar-access but returned no principal",
|
||||
)
|
||||
} else {
|
||||
Outcome.NotCalDav("no DAV:current-user-principal, and no calendar-access in OPTIONS")
|
||||
Outcome.NotCalDav(
|
||||
Outcome.Cause.NOT_A_DAV_SERVER,
|
||||
"no DAV:current-user-principal, and no calendar-access in OPTIONS",
|
||||
)
|
||||
}
|
||||
|
||||
if (davCapabilities.isNotEmpty() && !davCapabilities.contains("calendar-access")) {
|
||||
return Outcome.NotCalDav("OPTIONS advertises ${davCapabilities.joinToString()} but not calendar-access")
|
||||
return Outcome.NotCalDav(
|
||||
Outcome.Cause.NO_CALENDAR_SUPPORT,
|
||||
"OPTIONS advertises ${davCapabilities.joinToString()} but not calendar-access",
|
||||
)
|
||||
}
|
||||
|
||||
val principal = resource.location.resolve(href)
|
||||
?: return Outcome.Failed("principal href \"$href\" is not a usable URL")
|
||||
?: return Outcome.Failed(
|
||||
Outcome.Cause.SERVER_ERROR,
|
||||
"principal href \"$href\" is not a usable URL",
|
||||
)
|
||||
|
||||
return fromPrincipal(principal, movedTo = resource.permanentLocation)
|
||||
}
|
||||
@@ -178,10 +246,12 @@ class CalDavDiscovery(
|
||||
}
|
||||
homeSetResult.exceptionOrNull()?.let { error ->
|
||||
if (isUnauthorized(error)) return Outcome.NeedsAuthentication(listOf(principal.host))
|
||||
return Outcome.Failed(error.message ?: error.toString())
|
||||
return Outcome.Failed(causeOf(error), error.message ?: error.toString())
|
||||
}
|
||||
|
||||
if (homeSets.isEmpty()) return Outcome.Failed("principal has no calendar-home-set")
|
||||
if (homeSets.isEmpty()) {
|
||||
return Outcome.Failed(Outcome.Cause.NO_CALENDARS, "principal has no calendar-home-set")
|
||||
}
|
||||
|
||||
// Cross-host is legal and required, but never over plain HTTP: the
|
||||
// credentials follow the home set, and a downgrade would send them in the
|
||||
@@ -189,7 +259,10 @@ class CalDavDiscovery(
|
||||
val crossHost = homeSets.filter { it.host != principal.host }
|
||||
val insecure = homeSets.filter { principal.isHttps && !it.isHttps }
|
||||
if (insecure.isNotEmpty()) {
|
||||
return Outcome.Failed("calendar-home-set downgrades to HTTP: ${insecure.first()}")
|
||||
return Outcome.Failed(
|
||||
Outcome.Cause.INSECURE,
|
||||
"calendar-home-set downgrades to HTTP: ${insecure.first()}",
|
||||
)
|
||||
}
|
||||
|
||||
val collections = linkedMapOf<HttpUrl, TaskCollection>()
|
||||
@@ -228,6 +301,7 @@ class CalDavDiscovery(
|
||||
Outcome.NeedsAuthentication(needAuth.map { it.url.host }.distinct())
|
||||
} else {
|
||||
Outcome.Failed(
|
||||
Outcome.Cause.NO_CALENDARS,
|
||||
failures.firstOrNull()?.reason ?: "no calendar-home-set could be listed",
|
||||
)
|
||||
}
|
||||
@@ -249,5 +323,9 @@ class CalDavDiscovery(
|
||||
companion object {
|
||||
/** `https://host/path` → the URL, or null. Convenience for callers. */
|
||||
fun url(value: String): HttpUrl? = value.toHttpUrlOrNull()
|
||||
|
||||
private const val NOT_FOUND = 404
|
||||
private const val METHOD_NOT_ALLOWED = 405
|
||||
private const val NOT_IMPLEMENTED = 501
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
package de.jeanlucmakiola.caldav
|
||||
|
||||
import okhttp3.HttpUrl
|
||||
|
||||
/**
|
||||
* Which CalDAV service or server software an account talks to.
|
||||
*
|
||||
* Read off the two things an account already stores, so nothing extra is asked
|
||||
* of the server and no column has to be added: the **host**, which names a
|
||||
* hosted service outright, and the **principal URL's path**, whose shape is a
|
||||
* fingerprint of the software behind it.
|
||||
*
|
||||
* [hosted] is what decides how an account is *named* on screen. A hosted
|
||||
* service's host is boilerplate (`caldav.fastmail.com` for everyone), so its
|
||||
* brand is the name; self-hosted software runs on the user's own host, which is
|
||||
* the only thing that tells two of them apart.
|
||||
*/
|
||||
enum class CalDavProvider(
|
||||
val label: String,
|
||||
val hosted: Boolean,
|
||||
internal val domains: Set<String> = emptySet(),
|
||||
internal val principalMarkers: Set<String> = emptySet(),
|
||||
) {
|
||||
|
||||
/**
|
||||
* ownCloud serves `/remote.php/dav/` too and cannot be told apart from here.
|
||||
* Nextcloud is the far commoner of the two and the only one the add flow has
|
||||
* a browser login for, so the mark goes to it — and because a self-hosted
|
||||
* account is titled by its host, the name "Nextcloud" is never written next
|
||||
* to an ownCloud server, only its mark.
|
||||
*/
|
||||
NEXTCLOUD("Nextcloud", hosted = false, principalMarkers = setOf("/remote.php/dav/")),
|
||||
|
||||
BAIKAL("Baïkal", hosted = false, principalMarkers = setOf("/dav.php/")),
|
||||
|
||||
DAVICAL("DAViCal", hosted = false, principalMarkers = setOf("/caldav.php/")),
|
||||
|
||||
SOGO("SOGo", hosted = false, principalMarkers = setOf("/sogo/dav/")),
|
||||
|
||||
FASTMAIL(
|
||||
"Fastmail",
|
||||
hosted = true,
|
||||
domains = setOf("fastmail.com", "fastmail.fm", "messagingengine.com"),
|
||||
),
|
||||
|
||||
ICLOUD("iCloud", hosted = true, domains = setOf("icloud.com", "me.com", "mac.com")),
|
||||
|
||||
GOOGLE("Google", hosted = true, domains = setOf("gmail.com", "googlemail.com", "google.com")),
|
||||
|
||||
MAILBOX_ORG("mailbox.org", hosted = true, domains = setOf("mailbox.org")),
|
||||
|
||||
POSTEO("Posteo", hosted = true, domains = setOf("posteo.de", "posteo.net")),
|
||||
|
||||
ZOHO("Zoho", hosted = true, domains = setOf("zoho.com", "zoho.eu")),
|
||||
|
||||
YANDEX("Yandex", hosted = true, domains = setOf("yandex.ru", "yandex.com")),
|
||||
;
|
||||
|
||||
companion object {
|
||||
|
||||
/** The service a host belongs to, if it is one we know by name. */
|
||||
fun forHost(host: String): CalDavProvider? {
|
||||
val lower = host.lowercase().trimEnd('.')
|
||||
return entries.firstOrNull { provider ->
|
||||
provider.domains.any { lower == it || lower.endsWith(".$it") }
|
||||
}
|
||||
}
|
||||
|
||||
/** The service behind an email address or a typed URL, if any. */
|
||||
fun forInput(input: String): CalDavProvider? {
|
||||
val host = ServiceDiscovery.asBaseUrl(input)?.host
|
||||
?: ServiceDiscovery.domainOf(input)
|
||||
?: return null
|
||||
return forHost(host)
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider behind a principal URL: the host first, because a service
|
||||
* we know by name is not in doubt, then the path shape.
|
||||
*/
|
||||
fun forPrincipal(url: HttpUrl): CalDavProvider? =
|
||||
forHost(url.host) ?: forPath(url.encodedPath)
|
||||
|
||||
private fun forPath(path: String): CalDavProvider? {
|
||||
val lower = path.lowercase()
|
||||
return entries.firstOrNull { provider ->
|
||||
provider.principalMarkers.any { it in lower }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -162,14 +162,14 @@ class NextcloudLoginFlow(
|
||||
val root = json.parseToJsonElement(body).jsonObject
|
||||
val server = root["server"]?.jsonPrimitive?.content?.toHttpUrlOrNull()
|
||||
?: error("no server URL in poll response")
|
||||
// Scheme only. A host mismatch here must never discard the credentials:
|
||||
// the 200 is returned exactly once — the server deletes the row inside
|
||||
// poll() before returning — so throwing would burn the app password and
|
||||
// force the user through the whole flow again.
|
||||
requireSecureOrigin(flow.pollEndpoint, server)
|
||||
// ⚠️ Coerced, never refused — neither the host nor the scheme may discard
|
||||
// the credentials. The 200 is returned exactly once (the server deletes
|
||||
// the row inside poll() before answering), so a throw here burns a live
|
||||
// app password and leaves it dangling in the user's device list.
|
||||
val secureServer = secureOrigin(flow.pollEndpoint, server)
|
||||
PollResult.Approved(
|
||||
Credentials(
|
||||
server = server,
|
||||
server = secureServer,
|
||||
// ⚠️ loginName is what the user typed — possibly an email, an
|
||||
// LDAP-derived value, or the right name in the wrong case. It is
|
||||
// the Basic auth username and nothing else. Interpolating it into
|
||||
@@ -182,17 +182,14 @@ class NextcloudLoginFlow(
|
||||
}.getOrElse { PollResult.Failed(it.message ?: it.toString()) }
|
||||
|
||||
/**
|
||||
* The endpoint is generated from `overwrite.cli.url` / `overwriteprotocol` /
|
||||
* These URLs are generated from `overwrite.cli.url` / `overwriteprotocol` /
|
||||
* `trusted_proxies`, which are misconfigured on a large fraction of
|
||||
* self-hosted installs — so it is validated rather than trusted verbatim.
|
||||
* self-hosted installs — so they are validated rather than trusted verbatim.
|
||||
*
|
||||
* A downgrade to `http` is refused outright: the poll token is exchanged for a
|
||||
* long-lived app password, which makes it a credential-grade secret.
|
||||
*/
|
||||
/**
|
||||
* A downgrade to `http` is **fatal**. The poll token is exchanged for a
|
||||
* long-lived app password and the login URL takes the account password, so
|
||||
* both are credential-grade.
|
||||
* A downgrade to `http` is **fatal here and only here**: this runs *before*
|
||||
* the user has approved anything, and the login URL is where they type their
|
||||
* account password. There is nothing to lose by refusing, and everything to
|
||||
* lose by not.
|
||||
*/
|
||||
internal fun requireSecureOrigin(expected: HttpUrl, actual: HttpUrl) {
|
||||
if (expected.isHttps && !actual.isHttps) {
|
||||
@@ -200,6 +197,29 @@ class NextcloudLoginFlow(
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The same downgrade, on the *poll response*, where refusing is the wrong
|
||||
* answer.
|
||||
*
|
||||
* ⚠️ By this point the credential has already been issued, and Nextcloud
|
||||
* returns it **exactly once** — the row is deleted inside `poll()` before it
|
||||
* answers. Throwing here does not protect anything: it destroys a live app
|
||||
* password, leaves one dangling in the user's device list, and sends them
|
||||
* through the whole flow again. The comment on the host mismatch above says
|
||||
* precisely this, and the scheme deserves the same treatment.
|
||||
*
|
||||
* Coercing is strictly safer than either alternative, because the invariant
|
||||
* that matters is *what we do next*: we only ever talk to the coerced URL, so
|
||||
* the credential never travels in cleartext regardless of what the server
|
||||
* put in the JSON.
|
||||
*/
|
||||
internal fun secureOrigin(expected: HttpUrl, actual: HttpUrl): HttpUrl =
|
||||
if (expected.isHttps && !actual.isHttps) {
|
||||
actual.newBuilder().scheme("https").build()
|
||||
} else {
|
||||
actual
|
||||
}
|
||||
|
||||
/** A different host than the user typed — reported, not refused. */
|
||||
internal fun hostMismatchOf(expected: HttpUrl, actual: HttpUrl): HostMismatch? =
|
||||
if (expected.host != actual.host) HostMismatch(expected.host, actual.host) else null
|
||||
|
||||
@@ -9,14 +9,17 @@ import okhttp3.HttpUrl
|
||||
* account password"* is the single most common support ticket any CalDAV client
|
||||
* inherits. Detecting it at account-add time by domain turns a dead end into one
|
||||
* sentence of instruction.
|
||||
*
|
||||
* The domains themselves live on [CalDavProvider] — one list, so a provider the
|
||||
* accounts screen can mark is also a provider this can warn about.
|
||||
*/
|
||||
enum class ServerQuirk(val domains: Set<String>) {
|
||||
enum class ServerQuirk {
|
||||
|
||||
/** Fastmail: needs an app password, and CalDAV is not on the Basic plan. */
|
||||
FASTMAIL_APP_PASSWORD(setOf("fastmail.com", "fastmail.fm", "messagingengine.com")),
|
||||
FASTMAIL_APP_PASSWORD,
|
||||
|
||||
/** iCloud: app-specific password, and 2FA must be on to mint one. */
|
||||
ICLOUD_APP_SPECIFIC_PASSWORD(setOf("icloud.com", "me.com", "mac.com")),
|
||||
ICLOUD_APP_SPECIFIC_PASSWORD,
|
||||
|
||||
/**
|
||||
* Google: OAuth2-only, and it supports neither VTODO nor MKCALENDAR — its own
|
||||
@@ -24,7 +27,7 @@ enum class ServerQuirk(val domains: Set<String>) {
|
||||
* drops it as a target, so this is a refusal with an explanation rather than
|
||||
* a 401 the user cannot act on.
|
||||
*/
|
||||
GOOGLE_UNSUPPORTED(setOf("gmail.com", "googlemail.com", "google.com")),
|
||||
GOOGLE_UNSUPPORTED,
|
||||
|
||||
/**
|
||||
* Nextcloud's brute-force protection throttles then 429s **per source IP**, so
|
||||
@@ -32,27 +35,26 @@ enum class ServerQuirk(val domains: Set<String>) {
|
||||
* clients on that network. Not domain-detectable; set when a server identifies
|
||||
* itself. Kept here so the engine has one place to ask.
|
||||
*/
|
||||
NEXTCLOUD_BRUTE_FORCE_PROTECTED(emptySet()),
|
||||
NEXTCLOUD_BRUTE_FORCE_PROTECTED,
|
||||
;
|
||||
|
||||
companion object {
|
||||
|
||||
/** The quirk implied by an email address or a URL host, if any. */
|
||||
fun forInput(input: String): ServerQuirk? {
|
||||
val host = ServiceDiscovery.asBaseUrl(input)?.host
|
||||
?: ServiceDiscovery.domainOf(input)
|
||||
?: return null
|
||||
return forHost(host)
|
||||
}
|
||||
fun forInput(input: String): ServerQuirk? = CalDavProvider.forInput(input)?.quirk
|
||||
|
||||
fun forHost(host: String): ServerQuirk? {
|
||||
val lower = host.lowercase().trimEnd('.')
|
||||
return entries.firstOrNull { quirk ->
|
||||
quirk.domains.any { lower == it || lower.endsWith(".$it") }
|
||||
}
|
||||
}
|
||||
fun forHost(host: String): ServerQuirk? = CalDavProvider.forHost(host)?.quirk
|
||||
|
||||
fun forUrl(url: HttpUrl): ServerQuirk? = forHost(url.host)
|
||||
|
||||
private val CalDavProvider.quirk: ServerQuirk?
|
||||
get() = when (this) {
|
||||
CalDavProvider.FASTMAIL -> FASTMAIL_APP_PASSWORD
|
||||
CalDavProvider.ICLOUD -> ICLOUD_APP_SPECIFIC_PASSWORD
|
||||
CalDavProvider.GOOGLE -> GOOGLE_UNSUPPORTED
|
||||
CalDavProvider.NEXTCLOUD -> NEXTCLOUD_BRUTE_FORCE_PROTECTED
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
/** True when discovery should not even be attempted. */
|
||||
|
||||
@@ -71,9 +71,43 @@ object ServiceDiscovery {
|
||||
val trimmed = input.trim()
|
||||
if (trimmed.isEmpty()) return emptyList()
|
||||
|
||||
// A typed base URL is used as typed. PROPFIND on it can return principal,
|
||||
// home-set and collection in one response, so DNS is never consulted.
|
||||
asBaseUrl(trimmed)?.let { return listOf(Candidate(it, "base URL as typed")) }
|
||||
// A typed URL is taken as typed and DNS is never consulted — a user who
|
||||
// gave us an address meant it.
|
||||
asBaseUrl(trimmed)?.let { typed ->
|
||||
val candidates = mutableListOf<Candidate>()
|
||||
// ⚠️ A typed URL is not automatically a *DAV* URL, and the bare
|
||||
// origin is what people actually type. `https://cloud.example.com`
|
||||
// is the web UI: PROPFIND on it returns the 405 any web server
|
||||
// answers, which reads as "not a CalDAV server" about a perfectly
|
||||
// good one. RFC 6764 §6 exists precisely for this case, so the
|
||||
// well-known probe has to run here too — returning the typed URL as
|
||||
// the only candidate is what made a working Nextcloud undiscoverable.
|
||||
//
|
||||
// ⚠️ Built through `newBuilder`, not by interpolating `host`, which
|
||||
// returns an IPv6 literal *without* its brackets — "fd00::1", not
|
||||
// "[fd00::1]". Pasting that back into a URL yields a string OkHttp
|
||||
// will not parse, so both candidates would be dropped and a typed
|
||||
// IPv6 address would report as "not an address". `toString()` also
|
||||
// drops a default port on its own.
|
||||
val origin = typed.newBuilder()
|
||||
.encodedPath("/")
|
||||
.query(null)
|
||||
.fragment(null)
|
||||
.build()
|
||||
.toString()
|
||||
if (typed.encodedPath.trim('/').isEmpty()) {
|
||||
add(candidates, origin, WELL_KNOWN, "typed origin + .well-known")
|
||||
add(candidates, origin, "/", "typed origin + root")
|
||||
} else {
|
||||
// A deep URL may well be the DAV root itself, and PROPFIND on it
|
||||
// can return principal, home-set and collection in one response.
|
||||
// The well-known stays as the fallback for a path that was a
|
||||
// guess.
|
||||
candidates += Candidate(typed, "base URL as typed")
|
||||
add(candidates, origin, WELL_KNOWN, "typed host + .well-known")
|
||||
}
|
||||
return candidates
|
||||
}
|
||||
|
||||
val domain = domainOf(trimmed) ?: return emptyList()
|
||||
val candidates = mutableListOf<Candidate>()
|
||||
|
||||
@@ -191,7 +191,10 @@ class CalDavDiscoveryTest {
|
||||
// answer "not authorised" — which the user reads as a wrong password.
|
||||
val outcome = discovery.discover("http://cloud.example.com/dav/")
|
||||
assertThat(outcome).isInstanceOf(CalDavDiscovery.Outcome.Failed::class.java)
|
||||
assertThat((outcome as CalDavDiscovery.Outcome.Failed).reason).contains("http://")
|
||||
val failed = outcome as CalDavDiscovery.Outcome.Failed
|
||||
// The cause is what the UI renders; the detail is for logs only.
|
||||
assertThat(failed.cause).isEqualTo(CalDavDiscovery.Outcome.Cause.INSECURE)
|
||||
assertThat(failed.detail).contains("http://")
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package de.jeanlucmakiola.caldav
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrl
|
||||
import org.junit.Test
|
||||
|
||||
class CalDavProviderTest {
|
||||
|
||||
@Test
|
||||
fun `a hosted service is known by its host`() {
|
||||
assertThat(CalDavProvider.forInput("me@fastmail.com")).isEqualTo(CalDavProvider.FASTMAIL)
|
||||
assertThat(CalDavProvider.forHost("caldav.icloud.com")).isEqualTo(CalDavProvider.ICLOUD)
|
||||
assertThat(CalDavProvider.forHost("dav.mailbox.org")).isEqualTo(CalDavProvider.MAILBOX_ORG)
|
||||
assertThat(CalDavProvider.forHost("cloud.example.de")).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `self-hosted software is known by its principal path`() {
|
||||
val nextcloud = "https://cloud.example.de/remote.php/dav/principals/users/jo/".toHttpUrl()
|
||||
assertThat(CalDavProvider.forPrincipal(nextcloud)).isEqualTo(CalDavProvider.NEXTCLOUD)
|
||||
|
||||
val baikal = "https://dav.example.de/dav.php/principals/jo/".toHttpUrl()
|
||||
assertThat(CalDavProvider.forPrincipal(baikal)).isEqualTo(CalDavProvider.BAIKAL)
|
||||
|
||||
val unknown = "https://dav.example.de/jo/".toHttpUrl()
|
||||
assertThat(CalDavProvider.forPrincipal(unknown)).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `the host wins over the path`() {
|
||||
// Fastmail's principals sit under a path we know nothing about; the host
|
||||
// already named the service, so nothing else is consulted.
|
||||
val url = "https://caldav.fastmail.com/dav/principals/user/me@fastmail.com/".toHttpUrl()
|
||||
assertThat(CalDavProvider.forPrincipal(url)).isEqualTo(CalDavProvider.FASTMAIL)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hosted services are titled by brand, self-hosted ones by host`() {
|
||||
assertThat(CalDavProvider.FASTMAIL.hosted).isTrue()
|
||||
assertThat(CalDavProvider.NEXTCLOUD.hosted).isFalse()
|
||||
}
|
||||
}
|
||||
@@ -166,4 +166,42 @@ class NextcloudLoginFlowTest {
|
||||
assertThat(mismatch!!.actual).isEqualTo("cloud.example.com")
|
||||
assertThat(mismatch.message).contains("overwrite.cli.url")
|
||||
}
|
||||
|
||||
@Test fun `a downgraded server URL in the poll response is coerced, not refused`() {
|
||||
// ⚠️ The credential has already been issued and the 200 comes exactly
|
||||
// once — the row is deleted inside poll() before it answers. Throwing
|
||||
// here destroys a live app password and leaves it dangling in the user's
|
||||
// device list, for no protection at all.
|
||||
val flow = NextcloudLoginFlow(OkHttpClient(), "test")
|
||||
val expected = "https://cloud.example.com/login/v2/poll".toHttpUrl()
|
||||
val downgraded = "http://cloud.example.com".toHttpUrl()
|
||||
|
||||
val coerced = flow.secureOrigin(expected, downgraded)
|
||||
|
||||
assertThat(coerced.scheme).isEqualTo("https")
|
||||
assertThat(coerced.host).isEqualTo("cloud.example.com")
|
||||
}
|
||||
|
||||
@Test fun `an already-secure server URL is left alone`() {
|
||||
val flow = NextcloudLoginFlow(OkHttpClient(), "test")
|
||||
val expected = "https://cloud.example.com/login/v2/poll".toHttpUrl()
|
||||
val actual = "https://dav.example.com".toHttpUrl()
|
||||
|
||||
// A different host is reported by hostMismatchOf, never rewritten here.
|
||||
assertThat(flow.secureOrigin(expected, actual)).isEqualTo(actual)
|
||||
}
|
||||
|
||||
@Test fun `the login URL is still refused outright when downgraded`() {
|
||||
// Before approval there is nothing to lose by refusing, and the login URL
|
||||
// is where the *account* password gets typed.
|
||||
val flow = NextcloudLoginFlow(OkHttpClient(), "test")
|
||||
val failure = runCatching {
|
||||
flow.requireSecureOrigin(
|
||||
"https://cloud.example.com".toHttpUrl(),
|
||||
"http://cloud.example.com/login".toHttpUrl(),
|
||||
)
|
||||
}.exceptionOrNull()
|
||||
|
||||
assertThat(failure).isNotNull()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,8 +24,19 @@ class ServiceDiscoveryTest {
|
||||
@Test
|
||||
fun `a typed base URL is used as typed and never triggers DNS`() {
|
||||
val dns = FakeDns(srv = mapOf("_caldavs._tcp.example.com" to listOf(SrvRecord(0, 0, 8443, "dav.example.com"))))
|
||||
assertThat(urls("https://cloud.example.com/remote.php/dav/", dns))
|
||||
.containsExactly("https://cloud.example.com/remote.php/dav/")
|
||||
val candidates = urls("https://cloud.example.com/remote.php/dav/", dns)
|
||||
|
||||
// The typed path is tried first and the SRV target is never consulted:
|
||||
// a user who gave us an address meant it.
|
||||
assertThat(candidates.first()).isEqualTo("https://cloud.example.com/remote.php/dav/")
|
||||
assertThat(candidates).doesNotContain("https://dav.example.com:8443/")
|
||||
// The well-known follows as a fallback, because a typed path may have
|
||||
// been a guess — see `a typed deep URL is tried as typed, first`.
|
||||
assertThat(candidates)
|
||||
.containsExactly(
|
||||
"https://cloud.example.com/remote.php/dav/",
|
||||
"https://cloud.example.com/.well-known/caldav",
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -125,4 +136,59 @@ class ServiceDiscoveryTest {
|
||||
assertThat(ServiceDiscovery.candidatesFor("me@gmx.net", dns).map { it.origin })
|
||||
.contains("domain as typed + TXT path=/begenda/dav/users/")
|
||||
}
|
||||
|
||||
// --- a typed URL still gets the RFC 6764 probe -------------------------
|
||||
|
||||
@Test fun `a typed bare origin still tries well-known`() {
|
||||
// ⚠️ The case every user actually types. The origin is the *web UI*, and
|
||||
// PROPFIND on it returns the 405 any web server answers — which reads as
|
||||
// "not a CalDAV server" about a working Nextcloud. Found against a real
|
||||
// server, not by reading.
|
||||
val paths = ServiceDiscovery.candidatesFor("https://cloud.example.com")
|
||||
.map { it.url.encodedPath }
|
||||
|
||||
assertThat(paths).containsExactly("/.well-known/caldav", "/").inOrder()
|
||||
}
|
||||
|
||||
@Test fun `a trailing slash is still a bare origin`() {
|
||||
val paths = ServiceDiscovery.candidatesFor("https://cloud.example.com/")
|
||||
.map { it.url.encodedPath }
|
||||
|
||||
assertThat(paths).containsExactly("/.well-known/caldav", "/").inOrder()
|
||||
}
|
||||
|
||||
@Test fun `a typed deep URL is tried as typed, first`() {
|
||||
// A deep URL may be the DAV root itself, where one PROPFIND can return
|
||||
// principal, home-set and collection together.
|
||||
val paths = ServiceDiscovery.candidatesFor("https://cloud.example.com/remote.php/dav/")
|
||||
.map { it.url.encodedPath }
|
||||
|
||||
assertThat(paths.first()).isEqualTo("/remote.php/dav/")
|
||||
// …but the path may have been a guess, so the probe stays as a fallback.
|
||||
assertThat(paths).contains("/.well-known/caldav")
|
||||
}
|
||||
|
||||
@Test fun `a non-default port survives the origin rebuild`() {
|
||||
val candidates = ServiceDiscovery.candidatesFor("https://cloud.example.com:8443")
|
||||
|
||||
assertThat(candidates.map { it.url.toString() })
|
||||
.containsExactly(
|
||||
"https://cloud.example.com:8443/.well-known/caldav",
|
||||
"https://cloud.example.com:8443/",
|
||||
).inOrder()
|
||||
}
|
||||
|
||||
@Test fun `an IPv6 literal keeps its brackets through the origin rebuild`() {
|
||||
// ⚠️ `HttpUrl.host` hands back "fd00::1", not "[fd00::1]", so an origin
|
||||
// built by interpolating it is a string OkHttp will not parse — both
|
||||
// candidates were silently dropped and a homelab address reported as
|
||||
// "not an address".
|
||||
val candidates = ServiceDiscovery.candidatesFor("https://[fd00::1]:8443")
|
||||
|
||||
assertThat(candidates.map { it.url.toString() })
|
||||
.containsExactly(
|
||||
"https://[fd00::1]:8443/.well-known/caldav",
|
||||
"https://[fd00::1]:8443/",
|
||||
).inOrder()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user