Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2024 - 2025 Ping Identity Corporation. All rights reserved.
* Copyright (c) 2024 - 2026 Ping Identity Corporation. All rights reserved.
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
Expand All @@ -16,15 +16,19 @@ val headers = headersOf(HttpHeaders.ContentType, "application/json")

fun openIdConfigurationResponse() =
ByteReadChannel(
"{\n" +
" \"authorization_endpoint\" : \"http://auth.test-one-pingone.com/authorize\",\n" +
" \"token_endpoint\" : \"https://auth.test-one-pingone.com/token\",\n" +
" \"userinfo_endpoint\" : \"https://auth.test-one-pingone.com/userinfo\",\n" +
" \"end_session_endpoint\" : \"https://auth.test-one-pingone.com/signoff\",\n" +
" \"revocation_endpoint\" : \"https://auth.test-one-pingone.com/revoke\"\n" +
"}",
"""
{
"authorization_endpoint" : "http://auth.test-one-pingone.com/authorize",
"token_endpoint" : "https://auth.test-one-pingone.com/token",
"userinfo_endpoint" : "https://auth.test-one-pingone.com/userinfo",
"end_session_endpoint" : "https://auth.test-one-pingone.com/signoff",
"revocation_endpoint" : "https://auth.test-one-pingone.com/revoke",
"pushed_authorization_request_endpoint" : "https://auth.test-one-pingone.com/par"
}
""",
)


fun tokeResponse() =
ByteReadChannel(
"{\n" +
Expand Down
105 changes: 96 additions & 9 deletions davinci/src/test/kotlin/com/pingidentity/davinci/DaVinciTest.kt
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2024 - 2025 Ping Identity Corporation. All rights reserved.
* Copyright (c) 2024 - 2026 Ping Identity Corporation. All rights reserved.
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
Expand Down Expand Up @@ -105,10 +105,14 @@ class DaVinciTest {
respond(authorizeResponse(), HttpStatusCode.OK, authorizeResponseHeaders)
}

"/par" -> {
respond(parResponse(), HttpStatusCode.Created, headers)
}

else -> {
return@MockEngine respond(
content =
ByteReadChannel(""),
ByteReadChannel(""),
status = HttpStatusCode.InternalServerError,
)
}
Expand Down Expand Up @@ -179,9 +183,9 @@ class DaVinciTest {
assertTrue(node is ContinueNode)
assertTrue { (node as ContinueNode).collectors.size == 5 }
assertEquals("cq77vwelou", node.id)
assertEquals("Username/Password Form", node.name)
assertEquals("Test Description", node.description)
assertEquals("CUSTOM_HTML", node.category)
assertEquals("Username/Password Form", node.name)
assertEquals("Test Description", node.description)
assertEquals("CUSTOM_HTML", node.category)

(node.collectors[0] as? TextCollector)?.value = "My First Name"
(node.collectors[1] as? PasswordCollector)?.value = "My Password"
Expand Down Expand Up @@ -234,7 +238,10 @@ class DaVinciTest {

//Make sure the request to signoff is made
val signOff = mockEngine.requestHistory[5]
assertEquals("https://auth.test-one-pingone.com/signoff?id_token_hint=Dummy+IdToken&client_id=test", signOff.url.toString())
assertEquals(
"https://auth.test-one-pingone.com/signoff?id_token_hint=Dummy+IdToken&client_id=test",
signOff.url.toString()
)
assertContains(signOff.headers["Cookie"].toString(), "ST=session_token")
//Ensure storage are removed
assertNull(tokenStorage.get())
Expand Down Expand Up @@ -355,13 +362,17 @@ class DaVinciTest {
}

"/authorize" -> {
respond(ByteReadChannel(readFile("ResponseWithBasicType.json")), HttpStatusCode.OK, authorizeResponseHeaders)
respond(
ByteReadChannel(readFile("ResponseWithBasicType.json")),
HttpStatusCode.OK,
authorizeResponseHeaders
)
}

else -> {
return@MockEngine respond(
content =
ByteReadChannel(""),
ByteReadChannel(""),
status = HttpStatusCode.InternalServerError,
)
}
Expand Down Expand Up @@ -389,7 +400,12 @@ class DaVinciTest {
assertEquals(11, node.collectors.size)

(node.collectors[0] as? LabelCollector)?.content?.let { assertEquals("Sign On", it) }
(node.collectors[1] as? LabelCollector)?.content?.let { assertEquals("Welcome to Ping Identity", it) }
(node.collectors[1] as? LabelCollector)?.content?.let {
assertEquals(
"Welcome to Ping Identity",
it
)
}

(node.collectors[2] as? TextCollector)?.let {
assertEquals("TEXT", it.type)
Expand Down Expand Up @@ -464,4 +480,75 @@ class DaVinciTest {
assertEquals("default-checkbox", it.value)
}
}

@Test
fun `DaVinci with PAR enabled`() = runTest {
val tokenStorage = MemoryStorage<Token>()
val cookieStorage = MemoryStorage<Cookies>()
val daVinci =
DaVinci {
httpClient = KtorHttpClient(HttpClient(mockEngine))
// Oidc as module with PAR enabled
module(Oidc) {
clientId = "test"
discoveryEndpoint =
"http://localhost/.well-known/openid-configuration"
scopes = mutableSetOf("openid", "email", "address")
redirectUri = "http://localhost:8080"
storage = { tokenStorage }
par = true // Enable PAR
logger = Logger.STANDARD
}
module(Cookie) {
storage = { cookieStorage }
persist = mutableListOf("ST")
}
}

var node = daVinci.start() // Return first Node
assertTrue(node is ContinueNode)
assertTrue { (node as ContinueNode).collectors.size == 5 }

(node.collectors[0] as? TextCollector)?.value = "My First Name"
(node.collectors[1] as? PasswordCollector)?.value = "My Password"
(node.collectors[2] as? SubmitCollector)?.value = "click me"

node = node.next()
assertTrue(node is SuccessNode)

mockEngine.requestHistory[0] // well-known
val parRequest = mockEngine.requestHistory[1] // par
assertEquals("https://auth.test-one-pingone.com/par", parRequest.url.toString())
// Verify client_id and response_mode are in the POST body, not URL
assertTrue(parRequest.body is FormDataContent)
val parBody = parRequest.body as FormDataContent
assertEquals("test", parBody.formData["client_id"])
assertEquals("code", parBody.formData["response_type"])
assertEquals("pi.flow", parBody.formData["response_mode"])
assertEquals("openid email address", parBody.formData["scope"])
assertEquals("http://localhost:8080", parBody.formData["redirect_uri"])
assertNotNull(parBody.formData["code_challenge"])
assertEquals("S256", parBody.formData["code_challenge_method"])


// Verify PAR request was made
val authorizeRequest =
mockEngine.requestHistory[2] // authorize request (after well-known, authorize, customHTMLTemplate)
assertEquals(
"http://auth.test-one-pingone.com/authorize?response_mode=pi.flow&request_uri=urn%3Aietf%3Aparams%3Aoauth%3Arequest_uri%3Atest-request-uri&client_id=test",
authorizeRequest.url.toString()
)

// The token request should use the PAR flow
val tokenRequest = mockEngine.requestHistory[4] // token request
assertEquals("https://auth.test-one-pingone.com/token", tokenRequest.url.toString())
}

private fun parResponse(): String =
"""
{
"request_uri": "urn:ietf:params:oauth:request_uri:test-request-uri",
"expires_in": 60
}
""".trimIndent()
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2025 Ping Identity Corporation. All rights reserved.
* Copyright (c) 2025 - 2026 Ping Identity Corporation. All rights reserved.
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
Expand Down Expand Up @@ -33,4 +33,6 @@ object Constants {
const val STATE = "state"
const val UI_LOCATES = "ui_locales"
const val ACR_VALUES = "acr_values"
const val REQUEST_URI = "request_uri"
const val RESPONSE_MODE = "response_mode"
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2024 - 2025 Ping Identity Corporation. All rights reserved.
* Copyright (c) 2024 - 2026 Ping Identity Corporation. All rights reserved.
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
Expand Down Expand Up @@ -110,7 +110,19 @@ class OidcClientConfig {
lateinit var clientId: String

/**
* Set of scopes for OIDC.
* Enable PAR (Pushed Authorization Request) RFC 9126.
*
* When enabled:
* - Authorization parameters are pushed to the server before authorization
* - Improves security by preventing parameter tampering
* - Reduces authorization URL length
* - Requires PAR endpoint support from the OIDC provider
*/
var par = false

/**
* Set of OAuth2 scopes to request during authorization.
* Common scopes include: "openid", "profile", "email", "offline_access"
*/
var scopes = mutableSetOf<String>()

Expand Down Expand Up @@ -257,5 +269,6 @@ class OidcClientConfig {
this.acrValues = other.acrValues
this.additionalParameters = other.additionalParameters
this.httpClient = other.httpClient
this.par = other.par
}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2024 - 2025 Ping Identity Corporation. All rights reserved.
* Copyright (c) 2024 - 2026 Ping Identity Corporation. All rights reserved.
*
* This software may be modified and distributed under the terms
* of the MIT license. See the LICENSE file for details.
Expand All @@ -24,6 +24,8 @@ import kotlinx.serialization.Serializable
data class OpenIdConfiguration(
@SerialName("authorization_endpoint")
val authorizationEndpoint: String = "",
@SerialName("pushed_authorization_request_endpoint")
val pushAuthorizationRequestEndpoint: String = "",
@SerialName("token_endpoint")
val tokenEndpoint: String = "",
@SerialName("userinfo_endpoint")
Expand Down
Loading
Loading