Skip to content

Commit 5b9b71e

Browse files
committed
always expect
1 parent a01fcab commit 5b9b71e

5 files changed

Lines changed: 99 additions & 29 deletions

File tree

ktor-server/ktor-server-plugins/ktor-server-auth-saml/jvm/src/io/ktor/server/auth/saml/SamlAuthenticationProvider.kt

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -292,6 +292,11 @@ public class SamlAuthenticationProvider internal constructor(
292292
val samlResponse = parameters["SAMLResponse"]
293293

294294
when {
295+
samlRequest != null && samlResponse != null -> {
296+
logger.debug("SLO endpoint failed. Both `SAMLRequest` and `SAMLRequest` are present")
297+
call.respond(HttpStatusCode.BadRequest, "Malformed SAML request")
298+
}
299+
295300
samlRequest != null -> handleIdpLogoutRequest(samlRequest, parameters)
296301
samlResponse != null -> handleLogoutResponse(samlResponse, parameters)
297302
else -> {

ktor-server/ktor-server-plugins/ktor-server-auth-saml/jvm/src/io/ktor/server/auth/saml/SamlLogoutProcessor.kt

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -164,12 +164,15 @@ internal class SamlLogoutProcessor(
164164
signatureParam: String? = null,
165165
signatureAlgorithmParam: String? = null
166166
): LogoutResult {
167-
val responseXml = samlResponseBase64.decodeSamlMessage(isDeflated = binding == SamlBinding.HttpRedirect)
168-
val document: Document = LibSaml.parserPool.parse(responseXml.toByteArray().inputStream())
169-
val logoutResponse = document.documentElement.unmarshall<LogoutResponse>()
167+
val logoutResponse = withValidationException {
168+
val responseXml = samlResponseBase64.decodeSamlMessage(isDeflated = binding == SamlBinding.HttpRedirect)
169+
val document: Document = LibSaml.parserPool.parse(responseXml.toByteArray().inputStream())
170+
document.documentElement.unmarshall<LogoutResponse>()
171+
}
170172

171173
val inResponseTo = logoutResponse.inResponseTo
172-
samlAssert(expectedRequestId == null || inResponseTo == expectedRequestId) { "InResponseTo mismatch" }
174+
samlRequire(expectedRequestId) { "Unexpected logout response" }
175+
samlAssert(inResponseTo == expectedRequestId) { "InResponseTo mismatch" }
173176

174177
// Issuer is required for security - ensures the response is from the expected IdP
175178
val issuer = samlRequire(logoutResponse.issuer?.value) { "LogoutResponse Issuer is required" }

ktor-server/ktor-server-plugins/ktor-server-auth-saml/jvm/src/io/ktor/server/auth/saml/SamlResponseProcessor.kt

Lines changed: 11 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -98,36 +98,31 @@ internal class SamlResponseProcessor(
9898
* @throws SamlValidationException if validation fails
9999
*/
100100
suspend fun processResponse(samlResponseBase64: String, expectedRequestId: String?): SamlCredential {
101-
val samlResponseXml = String(bytes = Base64.decode(samlResponseBase64))
102-
val response = parseResponse(samlResponseXml).also { it.validate(expectedRequestId) }
101+
val response = parseResponse(samlResponseBase64).also { it.validate(expectedRequestId) }
103102
val assertion = response.extractAssertion().also { it.validate(expectedRequestId) }
104103
return SamlCredential(response, assertion)
105104
}
106105

107-
private fun parseResponse(xml: String): Response = withValidationException {
108-
val document: Document = LibSaml.parserPool.parse(ByteArrayInputStream(xml.toByteArray()))
106+
private fun parseResponse(samlResponseBase64: String): Response = withValidationException {
107+
val xmlStream = ByteArrayInputStream(Base64.decode(source = samlResponseBase64))
108+
val document: Document = LibSaml.parserPool.parse(xmlStream)
109109
document.documentElement.unmarshall<Response>()
110110
}
111111

112112
private fun Response.validate(expectedRequestId: String?) {
113113
val statusCode = status?.statusCode?.value
114-
if (statusCode != StatusCode.SUCCESS) {
114+
samlAssert(statusCode == StatusCode.SUCCESS) {
115115
val statusMessage = status?.statusMessage?.value ?: "No message"
116-
throw SamlValidationException("SAML response status is not Success: $statusCode - $statusMessage")
116+
"SAML response status is not Success: $statusCode - $statusMessage"
117117
}
118118

119-
when {
120-
expectedRequestId != null -> {
121-
samlAssert(inResponseTo == expectedRequestId) { "InResponseTo mismatch" }
122-
}
123-
124-
!allowIdpInitiatedSso -> throw SamlValidationException("IdP-initiated SSO is not allowed.")
119+
if (expectedRequestId != null) {
120+
samlAssert(inResponseTo == expectedRequestId) { "InResponseTo mismatch" }
121+
} else {
122+
samlAssert(allowIdpInitiatedSso) { "IdP-initiated SSO is not allowed." }
125123
}
126124

127-
val issuer = issuer?.value
128-
samlAssert(issuer == idpMetadata.entityId) { "Response issuer mismatch" }
129-
130-
val destination = destination
125+
samlAssert(issuer?.value == idpMetadata.entityId) { "Response issuer mismatch" }
131126
samlAssert(!requireDestination || destination != null) { "Response Destination is not present" }
132127
samlAssert(destination == null || destination == acsUrl) { "Response Destination mismatch" }
133128

ktor-server/ktor-server-plugins/ktor-server-auth-saml/jvm/src/io/ktor/server/auth/saml/SamlUtils.kt

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,11 @@ internal fun String.encodeSamlMessage(deflate: Boolean): String {
8282
}
8383
val bytesOut = ByteArrayOutputStream()
8484
val deflater = Deflater(Deflater.DEFLATED, true)
85-
DeflaterOutputStream(bytesOut, deflater).use { it.write(bytes) }
85+
try {
86+
DeflaterOutputStream(bytesOut, deflater).use { it.write(bytes) }
87+
} finally {
88+
deflater.end()
89+
}
8690
return Base64.encode(source = bytesOut.toByteArray())
8791
}
8892

@@ -98,8 +102,12 @@ internal fun String.decodeSamlMessage(isDeflated: Boolean): String {
98102
return decodedBytes.toString(Charsets.UTF_8)
99103
}
100104
val inflater = Inflater(true)
101-
val inflaterInputStream = InflaterInputStream(decodedBytes.inputStream(), inflater)
102-
return inflaterInputStream.readBytes().toString(Charsets.UTF_8)
105+
try {
106+
val inflaterInputStream = InflaterInputStream(decodedBytes.inputStream(), inflater)
107+
return inflaterInputStream.readBytes().toString(Charsets.UTF_8)
108+
} finally {
109+
inflater.end()
110+
}
103111
}
104112

105113
@Suppress("UNCHECKED_CAST")

ktor-server/ktor-server-plugins/ktor-server-auth-saml/jvm/test/io/ktor/server/auth/saml/SamlLogoutIntegrationTest.kt

Lines changed: 65 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -123,33 +123,79 @@ class SamlLogoutIntegrationTest {
123123
fun `test LogoutResponse processing with success and failure status`() = testApplication {
124124
configureSamlAuth(enableSingleLogout = true)
125125

126+
val testClient = noRedirectsClient()
127+
128+
// Initiate SP-initiated logout to populate session with logoutRequestId
129+
val initiateResponse = testClient.get("/test-logout")
130+
assertEquals(HttpStatusCode.Found, initiateResponse.status)
131+
val sessionCookie = initiateResponse.headers[HttpHeaders.SetCookie]
132+
assertNotNull(sessionCookie, "Session cookie should be set")
133+
val logoutRequestId = initiateResponse.headers["X-Logout-Request-Id"]
134+
assertNotNull(logoutRequestId, "LogoutRequest ID should be returned in header")
135+
136+
// Test SUCCESS case: InResponseTo matches the stored logoutRequestId
126137
val successResponseXml = SamlTestUtils.createLogoutResponse(
127-
inResponseTo = "_test_request_id",
138+
inResponseTo = logoutRequestId,
128139
statusCode = StatusCode.SUCCESS,
129140
issuer = IDP_ENTITY_ID,
130141
destination = SLO_URL
131142
)
132143
val successBase64 = SamlTestUtils.encodeForPost(successResponseXml)
133144

134-
val successResponse = client.post(SLO_PATH) {
145+
val successResponse = testClient.post(SLO_PATH) {
135146
contentType(ContentType.Application.FormUrlEncoded)
147+
header(HttpHeaders.Cookie, sessionCookie.substringBefore(";"))
136148
setBody("SAMLResponse=${successBase64.encodeURLParameter()}")
137149
}
138150

139151
assertEquals(HttpStatusCode.OK, successResponse.status)
140152
assertTrue(successResponse.bodyAsText().contains("Logout completed"))
141153

154+
// Test FAILURE case: InResponseTo does NOT match the stored logoutRequestId
155+
// Re-initiate logout to get a fresh session with logoutRequestId
156+
val reinitiateResponse = testClient.get("/test-logout")
157+
assertEquals(HttpStatusCode.Found, reinitiateResponse.status)
158+
val freshSessionCookie = reinitiateResponse.headers[HttpHeaders.SetCookie]
159+
assertNotNull(freshSessionCookie)
160+
161+
val mismatchedResponseXml = SamlTestUtils.createLogoutResponse(
162+
inResponseTo = "_different_request_id",
163+
statusCode = StatusCode.SUCCESS,
164+
issuer = IDP_ENTITY_ID,
165+
destination = SLO_URL
166+
)
167+
val mismatchedBase64 = SamlTestUtils.encodeForPost(mismatchedResponseXml)
168+
169+
val mismatchedResponse = testClient.post(SLO_PATH) {
170+
contentType(ContentType.Application.FormUrlEncoded)
171+
header(HttpHeaders.Cookie, freshSessionCookie.substringBefore(";"))
172+
setBody("SAMLResponse=${mismatchedBase64.encodeURLParameter()}")
173+
}
174+
175+
// Mismatched InResponseTo should result in BadRequest (InResponseTo mismatch is caught as validation error)
176+
assertEquals(HttpStatusCode.BadRequest, mismatchedResponse.status)
177+
assertTrue(mismatchedResponse.bodyAsText().contains("Invalid logout response"))
178+
179+
// Test IdP failure case: InResponseTo matches but IdP reports failure status
180+
val reinitiateResponse2 = testClient.get("/test-logout")
181+
assertEquals(HttpStatusCode.Found, reinitiateResponse2.status)
182+
val freshSessionCookie2 = reinitiateResponse2.headers[HttpHeaders.SetCookie]
183+
assertNotNull(freshSessionCookie2)
184+
val logoutRequestId2 = reinitiateResponse2.headers["X-Logout-Request-Id"]
185+
assertNotNull(logoutRequestId2)
186+
142187
val failureResponseXml = SamlTestUtils.createLogoutResponse(
143-
inResponseTo = "_test_request",
188+
inResponseTo = logoutRequestId2,
144189
statusCode = StatusCode.RESPONDER,
145190
statusMessage = "Logout failed at IdP",
146191
issuer = IDP_ENTITY_ID,
147192
destination = SLO_URL
148193
)
149194
val failureBase64 = SamlTestUtils.encodeForPost(failureResponseXml)
150195

151-
val failureResponse = client.post(SLO_PATH) {
196+
val failureResponse = testClient.post(SLO_PATH) {
152197
contentType(ContentType.Application.FormUrlEncoded)
198+
header(HttpHeaders.Cookie, freshSessionCookie2.substringBefore(";"))
153199
setBody("SAMLResponse=${failureBase64.encodeURLParameter()}")
154200
}
155201

@@ -162,16 +208,27 @@ class SamlLogoutIntegrationTest {
162208
fun `test LogoutResponse with RelayState redirects`() = testApplication {
163209
configureSamlAuth(enableSingleLogout = true)
164210

211+
val testClient = noRedirectsClient()
212+
213+
// Initiate SP-initiated logout to populate session with logoutRequestId
214+
val initiateResponse = testClient.get("/test-logout")
215+
assertEquals(HttpStatusCode.Found, initiateResponse.status)
216+
val sessionCookie = initiateResponse.headers[HttpHeaders.SetCookie]
217+
assertNotNull(sessionCookie)
218+
val logoutRequestId = initiateResponse.headers["X-Logout-Request-Id"]
219+
assertNotNull(logoutRequestId)
220+
165221
val logoutResponseXml = SamlTestUtils.createLogoutResponse(
166-
inResponseTo = "_test_request",
222+
inResponseTo = logoutRequestId,
167223
statusCode = StatusCode.SUCCESS,
168224
issuer = IDP_ENTITY_ID,
169225
destination = SLO_URL
170226
)
171227
val base64Response = SamlTestUtils.encodeForPost(logoutResponseXml)
172228

173-
val response = noRedirectsClient().post(SLO_PATH) {
229+
val response = testClient.post(SLO_PATH) {
174230
contentType(ContentType.Application.FormUrlEncoded)
231+
header(HttpHeaders.Cookie, sessionCookie.substringBefore(";"))
175232
setBody("SAMLResponse=${base64Response.encodeURLParameter()}&RelayState=/post-logout-page")
176233
}
177234

@@ -327,6 +384,8 @@ class SamlLogoutIntegrationTest {
327384
spMetadata = spMetadata,
328385
sessionIndex = "_session123"
329386
)
387+
// Include the messageId in a header for tests to use when constructing LogoutResponse
388+
call.response.header("X-Logout-Request-Id", result.messageId)
330389
call.respondRedirect(result.redirectUrl)
331390
}
332391

0 commit comments

Comments
 (0)