Skip to content

Server SAML Auth. Metadata generation endpoint#5418

Open
zibet27 wants to merge 3 commits into
zibet27/server-auth-saml-logoutfrom
zibet27/server-auth-saml-metadata-generator
Open

Server SAML Auth. Metadata generation endpoint#5418
zibet27 wants to merge 3 commits into
zibet27/server-auth-saml-logoutfrom
zibet27/server-auth-saml-metadata-generator

Conversation

@zibet27
Copy link
Copy Markdown
Collaborator

@zibet27 zibet27 commented Mar 3, 2026

Subsystem
Server Auth

Motivation
KTOR-601 SAML Support
Chunk of #5392

Solution
The metadata generator (SamlSpMetadata.toXml()) produces standard SAML 2.0 SP metadata XML that you can share with the IdP for registration.
It includes:

  • Entity ID and ACS endpoint (POST binding)
  • SLO endpoints (both redirect and POST bindings) if configured
  • Signing and encryption certificates from your keystore
  • Supported NameID formats
  • Optional organization info and contact persons

@zibet27 zibet27 requested review from bjhham, e5l, jk1 and osipxd March 3, 2026 12:37
@zibet27 zibet27 self-assigned this Mar 3, 2026
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 3, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 29f08459-889b-4854-a926-ac23baaad985

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Walkthrough

This change adds SAML Service Provider (SP) metadata generation capability to the Ktor SAML authentication plugin. It introduces a new public extension function SamlSpMetadata.toXml() that converts metadata to XML format using OpenSAML, with supporting test utilities and comprehensive test coverage.

Changes

Cohort / File(s) Summary
API Declaration
ktor-server-auth-saml.api
Adds public API declaration for new SamlMetadataGeneratorKt class with toXml(SamlSpMetadata): String function.
SAML Metadata Generation Implementation
ktor-server-auth-saml/.../SamlMetadataGenerator.kt
Introduces extension function SamlSpMetadata.toXml(): String that builds and serializes SAML SP metadata to XML. Includes logic for EntityDescriptor construction, AssertionConsumerService configuration, optional SLO endpoints, KeyDescriptor management for signing/encryption, NameIDFormat handling, organization information, and contact person entries. Uses private helpers for descriptor building, key descriptor creation, and OpenSAML object conversion.
Metadata Generation Tests
ktor-server-auth-saml/.../SamlMetadataGeneratorTest.kt
Comprehensive test suite validating metadata generation with test cases for minimal and complete metadata scenarios, required field validation, certificate inclusion logic for RSA vs. ECDSA credentials, and XML structure assertions with certificate masking helpers.
Test Utilities
ktor-server-auth-saml/.../TestUtil.kt
Adds generateEcdsaTestCredentials() function and createTempKeyStore() helper for creating temporary keystores from test credentials, enabling ECDSA credential testing scenarios.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 17.65% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title 'Server SAML Auth. Metadata generation endpoint' directly describes the main change: adding a metadata generation capability to the SAML authentication system.
Description check ✅ Passed The description includes all required template sections (Subsystem, Motivation, Solution) with concrete details about the metadata generator and its features.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch zibet27/server-auth-saml-metadata-generator

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@zibet27
Copy link
Copy Markdown
Collaborator Author

zibet27 commented Mar 3, 2026

@coderabbitai review

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Mar 3, 2026

✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
ktor-server/ktor-server-plugins/ktor-server-auth-saml/jvm/src/io/ktor/server/auth/saml/SamlMetadataGenerator.kt (1)

25-27: Validate metadata fields before initializing OpenSAML.

LibSaml.ensureInitialized() currently runs before argument checks. Reordering avoids unnecessary global init work and ensures invalid input fails fast with the documented IllegalArgumentException.

♻️ Proposed change
 public fun SamlSpMetadata.toXml(): String {
-    LibSaml.ensureInitialized()
     require(!spEntityId.isNullOrBlank()) { "spEntityId is required for SP metadata" }
     require(acsUrl.isNotBlank()) { "acsUrl is required for SP metadata" }
+    LibSaml.ensureInitialized()
 
     val entityDescriptor = buildEntityDescriptor(this)
     return entityDescriptor.marshalToString()
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@ktor-server/ktor-server-plugins/ktor-server-auth-saml/jvm/src/io/ktor/server/auth/saml/SamlMetadataGenerator.kt`
around lines 25 - 27, Move the input validation for spEntityId and acsUrl to run
before calling LibSaml.ensureInitialized(): validate that spEntityId is not
null/blank and acsUrl is not blank (the existing require checks referencing
spEntityId and acsUrl) and only then call LibSaml.ensureInitialized(); this
avoids initializing OpenSAML when the arguments are invalid and preserves the
documented IllegalArgumentException behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@ktor-server/ktor-server-plugins/ktor-server-auth-saml/jvm/test/io/ktor/server/auth/saml/SamlMetadataGeneratorTest.kt`:
- Around line 125-136: The tests currently pass a failure message into
assertFailsWith which doesn't check the thrown exception text; change both
assertions to capture the thrown exception and assert on its message: replace
assertFailsWith<IllegalArgumentException>("spEntityId is required") {
SamlSpMetadata { acsUrl = "..." }.toXml() } with val ex1 =
assertFailsWith<IllegalArgumentException> { SamlSpMetadata { acsUrl = "..."
}.toXml() } followed by assertEquals("spEntityId is required for SP metadata",
ex1.message), and do the same for the acsUrl case using assertFailsWith to get
ex2 and assertEquals("acsUrl is required for SP metadata", ex2.message); target
the SamlSpMetadata builder and toXml invocation when making these changes.

In
`@ktor-server/ktor-server-plugins/ktor-server-auth-saml/jvm/test/io/ktor/server/auth/saml/TestUtil.kt`:
- Around line 7-8: Replace the two explicit imports for generateCertificate and
saveToFile with the repository's configured star-import for the io.ktor package:
change the explicit imports from
io.ktor.network.tls.certificates.generateCertificate and
io.ktor.network.tls.certificates.saveToFile to a single wildcard import
io.ktor.network.tls.certificates.* so the TestUtil.kt complies with the
io.ktor.* star-import rule.

---

Nitpick comments:
In
`@ktor-server/ktor-server-plugins/ktor-server-auth-saml/jvm/src/io/ktor/server/auth/saml/SamlMetadataGenerator.kt`:
- Around line 25-27: Move the input validation for spEntityId and acsUrl to run
before calling LibSaml.ensureInitialized(): validate that spEntityId is not
null/blank and acsUrl is not blank (the existing require checks referencing
spEntityId and acsUrl) and only then call LibSaml.ensureInitialized(); this
avoids initializing OpenSAML when the arguments are invalid and preserves the
documented IllegalArgumentException behavior.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between a01fcab and 8a554d8.

📒 Files selected for processing (4)
  • ktor-server/ktor-server-plugins/ktor-server-auth-saml/api/ktor-server-auth-saml.api
  • ktor-server/ktor-server-plugins/ktor-server-auth-saml/jvm/src/io/ktor/server/auth/saml/SamlMetadataGenerator.kt
  • ktor-server/ktor-server-plugins/ktor-server-auth-saml/jvm/test/io/ktor/server/auth/saml/SamlMetadataGeneratorTest.kt
  • ktor-server/ktor-server-plugins/ktor-server-auth-saml/jvm/test/io/ktor/server/auth/saml/TestUtil.kt

Comment on lines +125 to +136
assertFailsWith<IllegalArgumentException>("spEntityId is required") {
SamlSpMetadata {
acsUrl = "https://sp.example.com/saml/acs"
}.toXml()
}

assertFailsWith<IllegalArgumentException>("acsUrl is required") {
SamlSpMetadata {
spEntityId = "https://sp.example.com"
acsUrl = ""
}.toXml()
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🌐 Web query:

kotlin.test assertFailsWith message parameter exception text verification

💡 Result:

In kotlin.test.assertFailsWith, the message parameter is not the expected exception’s text. It’s the assertion failure message prefix used only if the assertion fails (e.g., wrong type or no exception thrown). (kotlinlang.org)

To verify the thrown exception’s message/text, capture the returned exception and assert on exception.message:

import kotlin.test.*

`@Test`
fun throws_with_expected_message() {
  val ex = assertFailsWith<IllegalArgumentException> {
    error("boom") // or throw IllegalArgumentException("boom")
  }
  assertEquals("boom", ex.message)
}

assertFailsWith returns the caught exception specifically so you can inspect its properties (like message, cause, custom fields). (kotlinlang.org)

Citations:


🏁 Script executed:

# Check if there's a kotlin.test dependency or gradle config that reveals version info
fd -t f "build.gradle" "gradle.properties" "settings.gradle" | head -5 | xargs -I {} grep -l "kotlin" {} 2>/dev/null | head -3

Repository: ktorio/ktor

Length of output: 221


🏁 Script executed:

# Find the test file
fd -t f "SamlMetadataGeneratorTest.kt"

Repository: ktorio/ktor

Length of output: 171


🏁 Script executed:

# Check the implementation to see what exceptions are thrown
fd -t f "SamlSpMetadata" -e kt | head -10

Repository: ktorio/ktor

Length of output: 37


🏁 Script executed:

# Read the test file lines 125-136
cat -n ktor-server/ktor-server-plugins/ktor-server-auth-saml/jvm/test/io/ktor/server/auth/saml/SamlMetadataGeneratorTest.kt | sed -n '125,136p'

Repository: ktorio/ktor

Length of output: 554


🏁 Script executed:

# Find the SamlSpMetadata implementation
fd -t f "\.kt$" ktor-server/ktor-server-plugins/ktor-server-auth-saml | grep -v test | head -10

Repository: ktorio/ktor

Length of output: 1110


🏁 Script executed:

# Search for SamlSpMetadata class definition and where exceptions are thrown
rg -A 10 "class SamlSpMetadata" ktor-server/ktor-server-plugins/ktor-server-auth-saml/jvm/src/

Repository: ktorio/ktor

Length of output: 1536


🏁 Script executed:

# Search for the error messages in the source code
rg "spEntityId is required|acsUrl is required" ktor-server/ktor-server-plugins/ktor-server-auth-saml/jvm/src/

Repository: ktorio/ktor

Length of output: 438


Test assertions don't verify exception messages.

The string argument in assertFailsWith<T>(message, block) is only an assertion failure message—it doesn't validate the thrown exception's text. The actual error messages thrown by the implementation differ from the test strings (e.g., implementation throws "spEntityId is required for SP metadata" but test uses "spEntityId is required").

Capture the exception and assert on its message to properly validate error text:

✅ Suggested fix
-        assertFailsWith<IllegalArgumentException>("spEntityId is required") {
+        val missingEntityId = assertFailsWith<IllegalArgumentException> {
             SamlSpMetadata {
                 acsUrl = "https://sp.example.com/saml/acs"
             }.toXml()
         }
+        assertTrue(missingEntityId.message?.contains("spEntityId is required") == true)
 
-        assertFailsWith<IllegalArgumentException>("acsUrl is required") {
+        val missingAcs = assertFailsWith<IllegalArgumentException> {
             SamlSpMetadata {
                 spEntityId = "https://sp.example.com"
                 acsUrl = ""
             }.toXml()
         }
+        assertTrue(missingAcs.message?.contains("acsUrl is required") == true)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
assertFailsWith<IllegalArgumentException>("spEntityId is required") {
SamlSpMetadata {
acsUrl = "https://sp.example.com/saml/acs"
}.toXml()
}
assertFailsWith<IllegalArgumentException>("acsUrl is required") {
SamlSpMetadata {
spEntityId = "https://sp.example.com"
acsUrl = ""
}.toXml()
}
val missingEntityId = assertFailsWith<IllegalArgumentException> {
SamlSpMetadata {
acsUrl = "https://sp.example.com/saml/acs"
}.toXml()
}
assertTrue(missingEntityId.message?.contains("spEntityId is required") == true)
val missingAcs = assertFailsWith<IllegalArgumentException> {
SamlSpMetadata {
spEntityId = "https://sp.example.com"
acsUrl = ""
}.toXml()
}
assertTrue(missingAcs.message?.contains("acsUrl is required") == true)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@ktor-server/ktor-server-plugins/ktor-server-auth-saml/jvm/test/io/ktor/server/auth/saml/SamlMetadataGeneratorTest.kt`
around lines 125 - 136, The tests currently pass a failure message into
assertFailsWith which doesn't check the thrown exception text; change both
assertions to capture the thrown exception and assert on its message: replace
assertFailsWith<IllegalArgumentException>("spEntityId is required") {
SamlSpMetadata { acsUrl = "..." }.toXml() } with val ex1 =
assertFailsWith<IllegalArgumentException> { SamlSpMetadata { acsUrl = "..."
}.toXml() } followed by assertEquals("spEntityId is required for SP metadata",
ex1.message), and do the same for the acsUrl case using assertFailsWith to get
ex2 and assertEquals("acsUrl is required for SP metadata", ex2.message); target
the SamlSpMetadata builder and toXml invocation when making these changes.

@e5l e5l requested review from nomisRev and removed request for e5l March 30, 2026 08:30
Copy link
Copy Markdown
Contributor

@bjhham bjhham left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a couple cosmetic comments, but it looks good overall.

Comment on lines +50 to +65
// Add SLO endpoint if configured
val sloUrl = metadata.sloUrl
if (sloUrl.isNotBlank()) {
val sloRedirect = builderFactory.build<SingleLogoutService>(SingleLogoutService.DEFAULT_ELEMENT_NAME) {
binding = SAMLConstants.SAML2_REDIRECT_BINDING_URI
location = sloUrl
}
singleLogoutServices.add(sloRedirect)

// Also add POST binding for SLO
val sloPost = builderFactory.build<SingleLogoutService>(SingleLogoutService.DEFAULT_ELEMENT_NAME) {
binding = SAMLConstants.SAML2_POST_BINDING_URI
location = sloUrl
}
singleLogoutServices.add(sloPost)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor simplification here:

Suggested change
// Add SLO endpoint if configured
val sloUrl = metadata.sloUrl
if (sloUrl.isNotBlank()) {
val sloRedirect = builderFactory.build<SingleLogoutService>(SingleLogoutService.DEFAULT_ELEMENT_NAME) {
binding = SAMLConstants.SAML2_REDIRECT_BINDING_URI
location = sloUrl
}
singleLogoutServices.add(sloRedirect)
// Also add POST binding for SLO
val sloPost = builderFactory.build<SingleLogoutService>(SingleLogoutService.DEFAULT_ELEMENT_NAME) {
binding = SAMLConstants.SAML2_POST_BINDING_URI
location = sloUrl
}
singleLogoutServices.add(sloPost)
}
// Add SLO endpoint if configured
if (metadata.sloUrl.isNotBlank()) {
singleLogoutServices.addAll(
listOf(
SAMLConstants.SAML2_REDIRECT_BINDING_URI,
// Also add POST binding for SLO
SAMLConstants.SAML2_POST_BINDING_URI
).map { bindingUri ->
builderFactory.build(SingleLogoutService.DEFAULT_ELEMENT_NAME) {
binding = bindingUri
location = metadata.sloUrl
}
})
}

Comment on lines +90 to +103
// Add Organization if configured
if (metadata.organizationUrl != null ||
metadata.organizationName != null ||
metadata.organizationDisplayName != null
) {
organization = buildOrganization(metadata)
}

metadata.technicalContacts.forEach { contact: SamlContactPerson ->
contactPersons.add(contact.toOpenSaml(type = ContactPersonTypeEnumeration.TECHNICAL))
}
metadata.supportContacts.forEach { contact: SamlContactPerson ->
contactPersons.add(contact.toOpenSaml(type = ContactPersonTypeEnumeration.SUPPORT))
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can simplify with scoping here:

Suggested change
// Add Organization if configured
if (metadata.organizationUrl != null ||
metadata.organizationName != null ||
metadata.organizationDisplayName != null
) {
organization = buildOrganization(metadata)
}
metadata.technicalContacts.forEach { contact: SamlContactPerson ->
contactPersons.add(contact.toOpenSaml(type = ContactPersonTypeEnumeration.TECHNICAL))
}
metadata.supportContacts.forEach { contact: SamlContactPerson ->
contactPersons.add(contact.toOpenSaml(type = ContactPersonTypeEnumeration.SUPPORT))
}
with(metadata) {
// Add Organization if configured
if ((organizationUrl ?: organizationName ?: organizationDisplayName) != null) {
organization = buildOrganization(metadata)
}
technicalContacts.forEach { contact: SamlContactPerson ->
contactPersons.add(contact.toOpenSaml(type = ContactPersonTypeEnumeration.TECHNICAL))
}
supportContacts.forEach { contact: SamlContactPerson ->
contactPersons.add(contact.toOpenSaml(type = ContactPersonTypeEnumeration.SUPPORT))
}
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants