Skip to content

Commit affd59e

Browse files
committed
fix docs, fail eary on empty certificate
1 parent 90866c0 commit affd59e

File tree

7 files changed

+50
-18
lines changed

7 files changed

+50
-18
lines changed

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -78,14 +78,14 @@ public class SamlConfig internal constructor(
7878
*
7979
* ## Example
8080
* ```kotlin
81-
* val sp = SamlSpMetadata {
81+
* val spMetadata = SamlSpMetadata {
8282
* spEntityId = "https://myapp.example.com/saml/metadata"
8383
* acsUrl = "https://myapp.example.com/saml/acs"
84-
* keyStore { ... }
84+
* signingCredential = SamlCrypto.loadCredential(...)
8585
* }
8686
*
8787
* saml("saml-auth") {
88-
* sp = sp
88+
* sp = spMetadata
8989
* idp = parseSamlIdpMetadata(xmlString)
9090
* }
9191
* ```

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -366,8 +366,8 @@ private fun EntityDescriptor.extractIdPMetadata(validateCertificateExpiration: B
366366

367367
// Extract signing certificates
368368
val signingCredentials = idpDescriptor.extractSigningCredentials(validateCertificateExpiration)
369-
if (signingCredentials.isEmpty()) {
370-
LOGGER.warn("No signing certificates found in IdP metadata. Signature verification will fail.")
369+
require(signingCredentials.isNotEmpty()) {
370+
"No signing certificates found in IdP metadata. Signature verification will fail."
371371
}
372372

373373
return IdPMetadata(

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

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@ import org.opensaml.saml.saml2.core.*
99
/**
1010
* Represents an unverified SAML credential extracted from a SAML response.
1111
*
12-
* It should only be used during the authentication process and never exposed to application code.
13-
*
1412
* @property response The SAML response containing the assertion
1513
* @property assertion The SAML assertion (maybe decrypted)
1614
*/
@@ -139,14 +137,15 @@ public class SamlPrincipal(
139137
public fun hasAttribute(name: String): Boolean = attributes.containsKey(name)
140138
}
141139

142-
private fun Assertion.buildAttributesMap() = buildMap {
140+
private fun Assertion.buildAttributesMap(): Map<String, List<String>> = buildMap {
143141
attributeStatements.forEach { attributeStatement ->
144142
attributeStatement.attributes.forEach { attribute ->
143+
val name = attribute.name ?: return@forEach
145144
val values = attribute.attributeValues.mapNotNull { attributeValue ->
146145
attributeValue.dom?.textContent
147146
}
148147
if (values.isNotEmpty()) {
149-
put(attribute.name!!, values)
148+
put(name, this[name].orEmpty() + values)
150149
}
151150
}
152151
}

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,10 @@ public interface SamlReplayCache : AutoCloseable {
5353
/**
5454
* Atomically checks if an assertion ID has been seen and records it if not.
5555
* This method combines [isReplayed] and [recordAssertion] into a single atomic operation.
56+
*
57+
* `@param` assertionId The unique assertion ID to check and record
58+
* `@param` expirationTime The expiration time of the assertion (used for cache eviction)
59+
* `@return` true if the assertion was not seen before and was recorded; false if replay detected
5660
*/
5761
public suspend fun tryRecordAssertion(assertionId: String, expirationTime: Instant): Boolean
5862
}
@@ -93,6 +97,7 @@ public interface SamlReplayCache : AutoCloseable {
9397
*
9498
* @property maxSize Maximum number of assertion IDs to cache (default: 10,000).
9599
* When this limit is reached, the oldest entries are evicted.
100+
* @param parentScope Optional parent scope for cleanup coroutine lifecycle management
96101
*/
97102
@OptIn(ExperimentalTime::class)
98103
public class InMemorySamlReplayCache(

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

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -62,14 +62,20 @@ public object SamlCrypto {
6262
keyPassword: String,
6363
keystoreType: String = "JKS"
6464
): BasicX509Credential {
65-
val keyStore = loadKeyStore(keystorePath, keystorePassword, keystoreType)
66-
val key = keyStore.getKey(keyAlias, keyPassword.toCharArray()) as? PrivateKey
67-
?: throw KeyStoreException("Key with alias '$keyAlias' not found or is not a PrivateKey")
68-
val certificateChain = keyStore.getCertificateChain(keyAlias)
69-
?: throw KeyStoreException("Certificate chain for alias '$keyAlias' not found")
70-
val certificate = certificateChain.first() as? X509Certificate
71-
?: throw KeyStoreException("First certificate in chain is not an X509Certificate")
72-
return BasicX509Credential(certificate).also { it.setPrivateKey(key) }
65+
try {
66+
val keyStore = loadKeyStore(keystorePath, keystorePassword, keystoreType)
67+
val key = keyStore.getKey(keyAlias, keyPassword.toCharArray()) as? PrivateKey
68+
?: throw KeyStoreException("Key with alias '$keyAlias' not found or is not a PrivateKey")
69+
val certificateChain = keyStore.getCertificateChain(keyAlias)
70+
?: throw KeyStoreException("Certificate chain for alias '$keyAlias' not found")
71+
val certificate = certificateChain.firstOrNull() as? X509Certificate
72+
?: throw KeyStoreException("First certificate in chain is not an X509Certificate")
73+
return BasicX509Credential(certificate).also { it.setPrivateKey(key) }
74+
} catch (e: KeyStoreException) {
75+
throw e
76+
} catch (e: Throwable) {
77+
throw KeyStoreException("Failed to load credential", e)
78+
}
7379
}
7480
}
7581

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
package io.ktor.server.auth.saml
66

7+
import kotlinx.coroutines.Dispatchers
78
import kotlinx.coroutines.delay
89
import kotlinx.coroutines.joinAll
910
import kotlinx.coroutines.launch
@@ -60,7 +61,7 @@ class InMemorySamlReplayCacheTest {
6061
val assertionsPerCoroutine = 100
6162

6263
val jobs = (0 until coroutineCount).map { coroutineIndex ->
63-
launch {
64+
launch(Dispatchers.Default) {
6465
repeat(assertionsPerCoroutine) { assertionIndex ->
6566
val assertionId = "coroutine-$coroutineIndex-assertion-$assertionIndex"
6667
cache.recordAssertion(assertionId, expirationTime)

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,9 @@
44

55
package io.ktor.server.auth.saml
66

7+
import io.ktor.network.tls.certificates.*
8+
import java.security.cert.X509Certificate
9+
import kotlin.io.encoding.Base64
710
import kotlin.test.*
811
import kotlin.time.Duration.Companion.seconds
912

@@ -50,11 +53,29 @@ class SamlConfigTest {
5053
}
5154

5255
companion object {
56+
private val TEST_KEY_STORE = buildKeyStore {
57+
certificate("test") {
58+
password = "test"
59+
}
60+
}
61+
62+
private val TEST_CERTIFICATE = TEST_KEY_STORE.getCertificate("test") as X509Certificate
63+
64+
private val TEST_CERTIFICATE_BASE64 = Base64.encode(TEST_CERTIFICATE.encoded)
65+
5366
private val MINIMAL_IDP_METADATA = """
5467
<?xml version="1.0" encoding="UTF-8"?>
5568
<EntityDescriptor xmlns="urn:oasis:names:tc:SAML:2.0:metadata"
69+
xmlns:ds="http://www.w3.org/2000/09/xmldsig#"
5670
entityID="https://idp.example.com">
5771
<IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
72+
<KeyDescriptor use="signing">
73+
<ds:KeyInfo>
74+
<ds:X509Data>
75+
<ds:X509Certificate>$TEST_CERTIFICATE_BASE64</ds:X509Certificate>
76+
</ds:X509Data>
77+
</ds:KeyInfo>
78+
</KeyDescriptor>
5879
<SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
5980
Location="https://idp.example.com/sso"/>
6081
</IDPSSODescriptor>

0 commit comments

Comments
 (0)