diff --git a/ktor-client/ktor-client-plugins/ktor-client-auth/common/src/io/ktor/client/plugins/auth/providers/DigestAuthProvider.kt b/ktor-client/ktor-client-plugins/ktor-client-auth/common/src/io/ktor/client/plugins/auth/providers/DigestAuthProvider.kt index 650637c25c5..d037d509be7 100644 --- a/ktor-client/ktor-client-plugins/ktor-client-auth/common/src/io/ktor/client/plugins/auth/providers/DigestAuthProvider.kt +++ b/ktor-client/ktor-client-plugins/ktor-client-auth/common/src/io/ktor/client/plugins/auth/providers/DigestAuthProvider.kt @@ -1,5 +1,5 @@ /* - * Copyright 2014-2019 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + * Copyright 2014-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. */ package io.ktor.client.plugins.auth.providers @@ -9,6 +9,7 @@ import io.ktor.client.request.* import io.ktor.client.statement.* import io.ktor.http.* import io.ktor.http.auth.* +import io.ktor.http.auth.HeaderValueEncoding import io.ktor.util.* import io.ktor.utils.io.* import io.ktor.utils.io.charsets.* @@ -169,20 +170,21 @@ public class DigestAuthProvider( val token = makeDigest(tokenSequence.joinToString(":")) val auth = HttpAuthHeader.Parameterized( - AuthScheme.Digest, - linkedMapOf().apply { - realm?.let { this["realm"] = it } - serverOpaque?.let { this["opaque"] = it } - this["username"] = credentials.username - this["nonce"] = nonce - this["cnonce"] = clientNonce - this["response"] = hex(token) - this["uri"] = url.fullPath + authScheme = AuthScheme.Digest, + parameters = linkedMapOf().apply { + realm?.let { this["realm"] = it.quote() } + serverOpaque?.let { this["opaque"] = it.quote() } + this["username"] = credentials.username.quote() + this["nonce"] = nonce.quote() + this["cnonce"] = clientNonce.quote() + this["response"] = hex(token).quote() + this["uri"] = url.fullPath.quote() actualQop?.let { this["qop"] = it } - this["nc"] = nonceCount.toString() + this["nc"] = nonceCount.toString(radix = 16).padStart(length = 8, padChar = '0') @Suppress("DEPRECATION_ERROR") this["algorithm"] = algorithmName - } + }, + encoding = HeaderValueEncoding.QUOTED_WHEN_REQUIRED ) request.headers { diff --git a/ktor-client/ktor-client-plugins/ktor-client-auth/common/test/io/ktor/client/plugins/auth/DigestProviderTest.kt b/ktor-client/ktor-client-plugins/ktor-client-auth/common/test/io/ktor/client/plugins/auth/DigestProviderTest.kt index 4cc3f3fbb8b..a6195b54ba3 100644 --- a/ktor-client/ktor-client-plugins/ktor-client-auth/common/test/io/ktor/client/plugins/auth/DigestProviderTest.kt +++ b/ktor-client/ktor-client-plugins/ktor-client-auth/common/test/io/ktor/client/plugins/auth/DigestProviderTest.kt @@ -1,6 +1,6 @@ /* -* Copyright 2014-2021 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. -*/ + * Copyright 2014-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ @file:Suppress("DEPRECATION") @@ -21,14 +21,31 @@ class DigestProviderTest { private val paramValue = "value" - private val authAllFields = - "Digest algorithm=MD5, username=\"username\", realm=\"realm\", nonce=\"nonce\", qop=\"qop\", " + - "snonce=\"server-nonce\", cnonce=\"client-nonce\", uri=\"requested-uri\", " + - "request=\"client-digest\", message=\"message-digest\", opaque=\"opaque\"" - - private val authMissingQopAndOpaque = - "Digest algorithm=MD5, username=\"username\", realm=\"realm\", nonce=\"nonce\", snonce=\"server-nonce\", " + - "cnonce=\"client-nonce\", uri=\"requested-uri\", request=\"client-digest\", message=\"message-digest\"" + private val authAllFields = """ + Digest + algorithm=MD5, + username="username", + realm="realm", + nonce="nonce", + qop=qop, + cnonce="client-nonce", + uri="requested-uri", + request="client-digest", + message="message-digest", + opaque="opaque" + """.normalize() + + private val authMissingQopAndOpaque = """ + Digest + algorithm=MD5, + username="username", + realm="realm", + nonce="nonce", + cnonce="client-nonce", + uri="requested-uri", + request="client-digest", + message="message-digest" + """.normalize() private val digestAuthProvider by lazy { DigestAuthProvider({ DigestAuthCredentials("username", "password") }, "realm") @@ -55,9 +72,9 @@ class DigestProviderTest { runIsApplicable(authAllFields) val authHeader = addRequestHeaders(authAllFields) - assertTrue(authHeader.contains("qop=qop")) - assertTrue(authHeader.contains("opaque=opaque")) - checkStandardFields(authHeader) + authHeader.assertParameter("qop", expectedValue = "qop") + authHeader.assertParameter("opaque", expectedValue = "opaque".quote()) + authHeader.checkStandardParameters() } @Test @@ -72,7 +89,7 @@ class DigestProviderTest { providerWithoutRealm.addRequestHeaders(requestBuilder, authHeader) val resultAuthHeader = requestBuilder.headers[HttpHeaders.Authorization]!! - checkStandardFields(resultAuthHeader) + resultAuthHeader.checkStandardParameters() } @Test @@ -93,9 +110,9 @@ class DigestProviderTest { runIsApplicable(authMissingQopAndOpaque) val authHeader = addRequestHeaders(authMissingQopAndOpaque) - assertFalse(authHeader.contains("opaque=")) - assertFalse(authHeader.contains("qop=")) - checkStandardFields(authHeader) + authHeader.assertParameterNotSet("opaque") + authHeader.assertParameterNotSet("qop") + authHeader.checkStandardParameters() } @Test @@ -123,12 +140,21 @@ class DigestProviderTest { return requestBuilder.headers[HttpHeaders.Authorization]!! } - private fun checkStandardFields(authHeader: String) { - assertTrue(authHeader.contains("realm=realm")) - assertTrue(authHeader.contains("username=username")) - assertTrue(authHeader.contains("nonce=nonce")) + private fun String.checkStandardParameters() { + assertParameter("realm", expectedValue = "realm".quote()) + assertParameter("username", expectedValue = "username".quote()) + assertParameter("nonce", expectedValue = "nonce".quote()) + assertParameter("nc", expectedValue = "00000001") + assertParameter("uri", expectedValue = "/$path?$paramName=$paramValue".quote()) + } - val uriPattern = "uri=\"/$path?$paramName=$paramValue\"" - assertTrue(authHeader.contains(uriPattern)) + private fun String.assertParameter(name: String, expectedValue: String?) { + assertContains(this, "$name=$expectedValue") } + + private fun String.assertParameterNotSet(name: String) { + assertFalse(this.contains("$name=")) + } + + private fun String.normalize(): String = trimIndent().replace("\n", " ") } diff --git a/ktor-http/common/src/io/ktor/http/auth/HttpAuthHeader.kt b/ktor-http/common/src/io/ktor/http/auth/HttpAuthHeader.kt index c4baf94bbeb..b0ad55ff8be 100644 --- a/ktor-http/common/src/io/ktor/http/auth/HttpAuthHeader.kt +++ b/ktor-http/common/src/io/ktor/http/auth/HttpAuthHeader.kt @@ -1,6 +1,6 @@ /* -* Copyright 2014-2021 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. -*/ + * Copyright 2014-2024 JetBrains s.r.o and contributors. Use of this source code is governed by the Apache 2.0 license. + */ package io.ktor.http.auth @@ -394,22 +394,22 @@ public sealed class HttpAuthHeader(public val authScheme: String) { stale: Boolean? = null, algorithm: String = "MD5" ): Parameterized = Parameterized( - AuthScheme.Digest, - linkedMapOf().apply { - put("realm", realm) - put("nonce", nonce) + authScheme = AuthScheme.Digest, + parameters = linkedMapOf().apply { + put("realm", realm.quote()) + put("nonce", nonce.quote()) if (domain.isNotEmpty()) { - put("domain", domain.joinToString(" ")) + put("domain", domain.joinToString(" ").quote()) } if (opaque != null) { - put("opaque", opaque) + put("opaque", opaque.quote()) } if (stale != null) { put("stale", stale.toString()) } put("algorithm", algorithm) }, - HeaderValueEncoding.QUOTED_ALWAYS + encoding = HeaderValueEncoding.QUOTED_WHEN_REQUIRED ) } diff --git a/ktor-server/ktor-server-plugins/ktor-server-auth/jvm/test/io/ktor/tests/auth/DigestTest.kt b/ktor-server/ktor-server-plugins/ktor-server-auth/jvm/test/io/ktor/tests/auth/DigestTest.kt index a7c30b0d1ac..baf80bedfeb 100644 --- a/ktor-server/ktor-server-plugins/ktor-server-auth/jvm/test/io/ktor/tests/auth/DigestTest.kt +++ b/ktor-server/ktor-server-plugins/ktor-server-auth/jvm/test/io/ktor/tests/auth/DigestTest.kt @@ -28,7 +28,8 @@ class DigestTest { HttpAuthHeader.digestAuthChallenge( realm = "testrealm@host.com", nonce = "dcd98b7102dd2f0e8b11d0f600bfb0c093", - opaque = "5ccc069c403ebaf9f0171e9517f40e41" + opaque = "5ccc069c403ebaf9f0171e9517f40e41", + stale = false, ) ) ) @@ -40,11 +41,14 @@ class DigestTest { assertEquals(HttpStatusCode.Unauthorized, response.status) assertEquals( - """Digest - realm="testrealm@host.com", - nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", - opaque="5ccc069c403ebaf9f0171e9517f40e41", - algorithm="MD5" """.normalize(), + """ + Digest + realm="testrealm@host.com", + nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", + opaque="5ccc069c403ebaf9f0171e9517f40e41", + stale=false, + algorithm=MD5 + """.normalize(), response.headers[HttpHeaders.WWWAuthenticate] ) } @@ -70,15 +74,18 @@ class DigestTest { client.request("/") { header( HttpHeaders.Authorization, - """Digest username="Mufasa", - realm="testrealm@host.com", - nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", - uri="/dir/index.html", - qop=auth, - nc=00000001, - cnonce="0a4f113b", - response="6629fae49393a05397450978507c4ef1", - opaque="5ccc069c403ebaf9f0171e9517f40e41"""".normalize() + """ + Digest + username="Mufasa", + realm="testrealm@host.com", + nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", + uri="/dir/index.html", + qop=auth, + nc=00000001, + cnonce="0a4f113b", + response="6629fae49393a05397450978507c4ef1", + opaque="5ccc069c403ebaf9f0171e9517f40e41" + """.normalize() ) } @@ -96,15 +103,18 @@ class DigestTest { @Test fun testVerify() = runTest { - val authHeaderContent = """Digest username="Mufasa", - realm="testrealm@host.com", - nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", - uri="/dir/index.html", - qop=auth, - nc=00000001, - cnonce="0a4f113b", - response="6629fae49393a05397450978507c4ef1", - opaque="5ccc069c403ebaf9f0171e9517f40e41"""".normalize() + val authHeaderContent = """ + Digest + username="Mufasa", + realm="testrealm@host.com", + nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", + uri="/dir/index.html", + qop=auth, + nc=00000001, + cnonce="0a4f113b", + response="6629fae49393a05397450978507c4ef1", + opaque="5ccc069c403ebaf9f0171e9517f40e41" + """.normalize() val authHeader = parseAuthorizationHeader(authHeaderContent) as HttpAuthHeader.Parameterized val digest = authHeader.toDigestCredential() @@ -144,15 +154,18 @@ class DigestTest { val responseWrongAuth = client.get("/") { header( HttpHeaders.Authorization, - """Digest username="Mufasa", - realm="testrealm@host.com", - nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", - uri="/dir/index.html", - qop=auth, - nc=00000001, - cnonce="0a4f113b", - response="6629fae49393a05397450978507c4ef1", - opaque="5ccc069c403ebaf9f0171e9517f40e41"""".normalize() + """ + Digest + username="Mufasa", + realm="testrealm@host.com", + nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", + uri="/dir/index.html", + qop=auth, + nc=00000001, + cnonce="0a4f113b", + response="6629fae49393a05397450978507c4ef1", + opaque="5ccc069c403ebaf9f0171e9517f40e41" + """.normalize() ) } assertEquals(HttpStatusCode.Unauthorized, responseWrongAuth.status) @@ -160,15 +173,18 @@ class DigestTest { val responseCorrectAuth = client.get("/") { header( HttpHeaders.Authorization, - """Digest username="admin", - realm="testrealm@host.com", - nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", - uri="/dir/index.html", - qop=auth, - nc=00000001, - cnonce="0a4f113b", - response="b9b12c2f6abe2d166e5743ed1e687ed6", - opaque="5ccc069c403ebaf9f0171e9517f40e41"""".normalize() + """ + Digest + username="admin", + realm="testrealm@host.com", + nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", + uri="/dir/index.html", + qop=auth, + nc=00000001, + cnonce="0a4f113b", + response="b9b12c2f6abe2d166e5743ed1e687ed6", + opaque="5ccc069c403ebaf9f0171e9517f40e41" + """.normalize() ) } assertEquals(HttpStatusCode.OK, responseCorrectAuth.status) @@ -203,15 +219,18 @@ class DigestTest { val response = client.request("/") { header( HttpHeaders.Authorization, - """Digest username="Mufasa", - realm="testrealm@host.com", - nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", - uri="/dir/index.html", - qop=auth, - nc=00000001, - cnonce="0a4f113b", - response="6629fae49393a05397450978507c4ef1", - opaque="5ccc069c403ebaf9f0171e9517f40e41"""".normalize() + """ + Digest + username="Mufasa", + realm="testrealm@host.com", + nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", + uri="/dir/index.html", + qop=auth, + nc=00000001, + cnonce="0a4f113b", + response="6629fae49393a05397450978507c4ef1", + opaque="5ccc069c403ebaf9f0171e9517f40e41" + """.normalize() ) } @@ -226,15 +245,18 @@ class DigestTest { val response = client.request("/") { header( HttpHeaders.Authorization, - """Digest username="Mufasa", - realm="testrealm@host.com1", - nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", - uri="/dir/index.html", - qop=auth, - nc=00000001, - cnonce="0a4f113b", - response="6629fae49393a05397450978507c4ef1", - opaque="5ccc069c403ebaf9f0171e9517f40e41"""".normalize() + """ + Digest + username="Mufasa", + realm="testrealm@host.com1", + nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", + uri="/dir/index.html", + qop=auth, + nc=00000001, + cnonce="0a4f113b", + response="6629fae49393a05397450978507c4ef1", + opaque="5ccc069c403ebaf9f0171e9517f40e41" + """.normalize() ) } @@ -257,15 +279,18 @@ class DigestTest { val response = client.request("/") { header( HttpHeaders.Authorization, - """Digest username="Mufasa", - realm="testrealm@host.com", - nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", - uri="/dir/index.html", - qop=auth, - nc=00000001, - cnonce="0a4f113b", - response="bad response goes here 507c4ef1", - opaque="5ccc069c403ebaf9f0171e9517f40e41"""".normalize() + """ + Digest + username="Mufasa", + realm="testrealm@host.com", + nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", + uri="/dir/index.html", + qop=auth, + nc=00000001, + cnonce="0a4f113b", + response="bad response goes here 507c4ef1", + opaque="5ccc069c403ebaf9f0171e9517f40e41" + """.normalize() ) } @@ -279,15 +304,18 @@ class DigestTest { val response = client.request("/") { header( HttpHeaders.Authorization, - """Digest username="missing", - realm="testrealm@host.com", - nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", - uri="/dir/index.html", - qop=auth, - nc=00000001, - cnonce="0a4f113b", - response="6629fae49393a05397450978507c4ef1", - opaque="5ccc069c403ebaf9f0171e9517f40e41"""".normalize() + """ + Digest + username="missing", + realm="testrealm@host.com", + nonce="dcd98b7102dd2f0e8b11d0f600bfb0c093", + uri="/dir/index.html", + qop=auth, + nc=00000001, + cnonce="0a4f113b", + response="6629fae49393a05397450978507c4ef1", + opaque="5ccc069c403ebaf9f0171e9517f40e41" + """.normalize() ) } @@ -379,5 +407,5 @@ class DigestTest { return digester.digest() } - private fun String.normalize() = lineSequence().map { it.trim() }.joinToString(" ") + private fun String.normalize() = trimIndent().replace("\n", " ") }