Skip to content
Open
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,3 +10,4 @@ local.properties
xcuserdata
.kotlin
.polaris/
.claude/settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@ fun openIdConfigurationResponse() =
"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"
"pushed_authorization_request_endpoint" : "https://auth.test-one-pingone.com/par",
"device_authorization_endpoint" : "https://auth.test-one-pingone.com/tenantId/as/device_authorization"
}
""",
)
Expand Down
202 changes: 202 additions & 0 deletions davinci/src/test/kotlin/com/pingidentity/davinci/DaVinciTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

package com.pingidentity.davinci

import android.net.Uri
import com.pingidentity.davinci.collector.FlowCollector
import com.pingidentity.davinci.collector.LabelCollector
import com.pingidentity.davinci.collector.MultiSelectCollector
Expand All @@ -26,8 +27,10 @@ import com.pingidentity.logger.Logger
import com.pingidentity.logger.STANDARD
import com.pingidentity.network.ktor.KtorHttpClient
import com.pingidentity.oidc.Token
import com.pingidentity.oidc.module.VERIFICATION_URI_COMPLETE
import com.pingidentity.oidc.module.user
import com.pingidentity.orchestrate.ContinueNode
import com.pingidentity.orchestrate.FailureNode
import com.pingidentity.orchestrate.SuccessNode
import com.pingidentity.orchestrate.module.Cookie
import com.pingidentity.orchestrate.module.Cookies
Expand Down Expand Up @@ -110,6 +113,10 @@ class DaVinciTest {
respond(parResponse(), HttpStatusCode.Created, headers)
}

"/tenantId/applications/test/deviceFlow" -> {
respond(customHTMLTemplate(), HttpStatusCode.OK, customHTMLTemplateHeaders)
}

else -> {
return@MockEngine respond(
content =
Expand Down Expand Up @@ -545,6 +552,201 @@ class DaVinciTest {
assertEquals("https://auth.test-one-pingone.com/token", tokenRequest.url.toString())
}

@Test
fun `DaVinci with device user code navigates to deviceFlow URL on start`() = runTest {
val tokenStorage = MemoryStorage<Token>()
val verificationUriComplete =
"https://auth.test-one-pingone.com/tenantId/applications/test/deviceFlow?user_code=WDJB-MJHT"

val daVinci = DaVinci {
httpClient = KtorHttpClient(HttpClient(mockEngine))
module(Oidc) {
clientId = "test"
discoveryEndpoint = "http://localhost/.well-known/openid-configuration"
scopes = mutableSetOf("openid", "email", "address")
redirectUri = "http://localhost:8080"
storage = { tokenStorage }
}
module(Cookie) {
storage = { MemoryStorage() }
persist = mutableListOf("ST")
}
}

val node = daVinci.start {
VERIFICATION_URI_COMPLETE to Uri.parse(verificationUriComplete)
}

assertTrue(node is SuccessNode)

// Verify the device flow verification GET was made (not the normal /authorize)
val paths = mockEngine.requestHistory.map { it.url.encodedPath }
assertTrue(paths.none { it == "/authorize" }, "authorize should not be called in device flow")
assertTrue(
paths.any { it == "/tenantId/applications/test/deviceFlow" },
"deviceFlow endpoint should be called"
)

// Verify the deviceFlow request has userCode as a query parameter
val deviceFlowReq = mockEngine.requestHistory.first { it.url.encodedPath == "/tenantId/applications/test/deviceFlow" }
assertEquals("WDJB-MJHT", deviceFlowReq.url.parameters["userCode"])
}
Comment on lines +590 to +593
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 | ⚡ Quick win

Use the correct query parameter key in assertion.

The test checks userCode, but this flow passes user_code. This can fail for the right implementation or lock in the wrong contract.

Proposed fix
-        assertEquals("WDJB-MJHT", deviceFlowReq.url.parameters["userCode"])
+        assertEquals("WDJB-MJHT", deviceFlowReq.url.parameters["user_code"])
📝 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
// Verify the deviceFlow request has userCode as a query parameter
val deviceFlowReq = mockEngine.requestHistory.first { it.url.encodedPath == "/tenantId/applications/test/deviceFlow" }
assertEquals("WDJB-MJHT", deviceFlowReq.url.parameters["userCode"])
}
// Verify the deviceFlow request has userCode as a query parameter
val deviceFlowReq = mockEngine.requestHistory.first { it.url.encodedPath == "/tenantId/applications/test/deviceFlow" }
assertEquals("WDJB-MJHT", deviceFlowReq.url.parameters["user_code"])
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@davinci/src/test/kotlin/com/pingidentity/davinci/DaVinciTest.kt` around lines
590 - 593, The assertion in DaVinciTest.kt is using the wrong query parameter
name; update the check that inspects deviceFlowReq.url.parameters to assert the
"user_code" key (not "userCode") is present and equals "WDJB-MJHT" so the test
matches the actual device flow parameter naming used where deviceFlowReq and
mockEngine.requestHistory are examined.


@Test
fun `DaVinci with device user code skips token exchange on success`() = runTest {
val tokenStorage = MemoryStorage<Token>()
val verificationUriComplete =
"https://auth.test-one-pingone.com/tenantId/applications/test/deviceFlow?user_code=WDJB-MJHT"

val daVinci = DaVinci {
httpClient = KtorHttpClient(HttpClient(mockEngine))
module(Oidc) {
clientId = "test"
discoveryEndpoint = "http://localhost/.well-known/openid-configuration"
scopes = mutableSetOf("openid", "email", "address")
redirectUri = "http://localhost:8080"
storage = { tokenStorage }
}
module(Cookie) {
storage = { MemoryStorage() }
persist = mutableListOf("ST")
}
}

val node = daVinci.start {
VERIFICATION_URI_COMPLETE to Uri.parse(verificationUriComplete)
}

assertTrue(node is SuccessNode)

// Token exchange must be skipped — /token should not appear in request history
val paths = mockEngine.requestHistory.map { it.url.encodedPath }
assertTrue(paths.none { it == "/token" }, "token endpoint should not be called in device flow")
// Token storage must remain empty because exchange was skipped
// The approving device completes auth, the token is held by the requesting device
assertNull(tokenStorage.get())
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit/suggestion: This is intentional and correct behaviour — the approving device doesn't get a token, only the requesting device does. But it's the kind of thing that might surprise a future developer. The existing comment on line 625 is good; consider adding a short note that this is by design per RFC 8628 (the approving device completes auth in the browser, the token is held by the requesting device). Makes the intent clear to anyone who stumbles across this and wonders if the assertion is wrong.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Updated.

}

@Test
fun `DaVinci with device user code returns ErrorNode when deviceFlow endpoint returns 4xx`() = runTest {
val failingEngine = MockEngine { request ->
when (request.url.encodedPath) {
"/.well-known/openid-configuration" ->
respond(openIdConfigurationResponse(), HttpStatusCode.OK, headers)
"/tenantId/applications/test/deviceFlow" ->
respond(
ByteReadChannel("""{"message":"User denied access"}"""),
HttpStatusCode.Forbidden,
headers
)
else -> respond(ByteReadChannel(""), HttpStatusCode.InternalServerError)
}
}

val daVinci = DaVinci {
httpClient = KtorHttpClient(HttpClient(failingEngine))
module(Oidc) {
clientId = "test"
discoveryEndpoint = "http://localhost/.well-known/openid-configuration"
scopes = mutableSetOf("openid", "email", "address")
redirectUri = "http://localhost:8080"
storage = { MemoryStorage() }
}
module(Cookie) {
storage = { MemoryStorage() }
}
}

val node = daVinci.start {
VERIFICATION_URI_COMPLETE to
Uri.parse("https://auth.test-one-pingone.com/tenantId/applications/test/deviceFlow?user_code=WDJB-MJHT")
}

// DaVinci treats non-timeout 4xx as ErrorNode (recoverable)
assertTrue(node is com.pingidentity.orchestrate.ErrorNode)
assertEquals("User denied access", node.message)

failingEngine.close()
}

@Test
fun `DaVinci with device user code returns FailureNode when deviceFlow endpoint returns 5xx`() = runTest {
val failingEngine = MockEngine { request ->
when (request.url.encodedPath) {
"/.well-known/openid-configuration" ->
respond(openIdConfigurationResponse(), HttpStatusCode.OK, headers)
"/tenantId/applications/test/deviceFlow" ->
respond(
ByteReadChannel("""{"message":"Internal server error"}"""),
HttpStatusCode.InternalServerError,
headers
)
else -> respond(ByteReadChannel(""), HttpStatusCode.InternalServerError)
}
}

val daVinci = DaVinci {
httpClient = KtorHttpClient(HttpClient(failingEngine))
module(Oidc) {
clientId = "test"
discoveryEndpoint = "http://localhost/.well-known/openid-configuration"
scopes = mutableSetOf("openid", "email", "address")
redirectUri = "http://localhost:8080"
storage = { MemoryStorage() }
}
module(Cookie) {
storage = { MemoryStorage() }
}
}

val node = daVinci.start {
VERIFICATION_URI_COMPLETE to
Uri.parse("https://auth.test-one-pingone.com/tenantId/applications/test/deviceFlow?user_code=WDJB-MJHT")
}

assertTrue(node is FailureNode)
assertTrue(node.cause is com.pingidentity.exception.ApiException)
assertEquals(500, (node.cause as com.pingidentity.exception.ApiException).status)

failingEngine.close()
}

@Test
fun `DaVinci without device user code proceeds with normal authorize flow`() = runTest {
val tokenStorage = MemoryStorage<Token>()

val daVinci = DaVinci {
httpClient = KtorHttpClient(HttpClient(mockEngine))
module(Oidc) {
clientId = "test"
discoveryEndpoint = "http://localhost/.well-known/openid-configuration"
scopes = mutableSetOf("openid", "email", "address")
redirectUri = "http://localhost:8080"
storage = { tokenStorage }
}
module(Cookie) {
storage = { MemoryStorage() }
persist = mutableListOf("ST")
}
}

var node = daVinci.start()
assertTrue(node is ContinueNode)
(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)

// Normal flow goes through /authorize and /token
val paths = mockEngine.requestHistory.map { it.url.encodedPath }
assertTrue(paths.contains("/authorize"), "normal flow must call /authorize")
assertTrue(paths.contains("/token"), "normal flow must call /token")
assertTrue(paths.none { it.contains("deviceFlow") }, "deviceFlow must not be called in normal flow")
assertNotNull(tokenStorage.get())
}

private fun parResponse(): String =
"""
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,4 +35,8 @@ object Constants {
const val ACR_VALUES = "acr_values"
const val REQUEST_URI = "request_uri"
const val RESPONSE_MODE = "response_mode"
const val USER_CODE = "user_code"
const val USER_CODE_CAMEL = "userCode"
const val DEVICE_CODE = "device_code"
const val URN_DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
/*
* Copyright (c) 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.
*/

package com.pingidentity.oidc

import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable

/**
* Data class representing the RFC 8628 device authorization response.
*
* @property deviceCode The device verification code.
* @property userCode The end-user verification code.
* @property verificationUri The end-user verification URI.
* @property verificationUriComplete The end-user verification URI that includes the user code (optional).
* @property expiresIn The lifetime in seconds of the device code and user code.
* @property interval The minimum amount of time in seconds that the client should wait between polling requests.
*/
@Serializable
data class DeviceAuthorizationResponse(
@SerialName("device_code")
val deviceCode: String,
@SerialName("user_code")
val userCode: String,
@SerialName("verification_uri")
val verificationUri: String,
@SerialName("verification_uri_complete")
val verificationUriComplete: String? = null,
@SerialName("expires_in")
val expiresIn: Int,
@SerialName("interval")
val interval: Int = 5,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
/*
* Copyright (c) 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.
*/

package com.pingidentity.oidc

/**
* Sealed class representing the status of a device authorization flow (RFC 8628).
*/
sealed class DeviceFlowStatus {

/**
* The device authorization request succeeded and the user code has been obtained.
*
* @property response The device authorization response containing the user code, verification URI, etc.
*/
data class Started(val response: DeviceAuthorizationResponse) : DeviceFlowStatus()

/**
* The client is polling the token endpoint waiting for the user to authorize.
*
* @property pollCount The number of polling attempts made so far.
* @property pollInterval The current polling interval in seconds.
* @property nextPollAt The wall-clock time (epoch millis) of the next scheduled poll.
*/
data class Polling(
val pollCount: Int,
val pollInterval: Int,
val nextPollAt: Long,
) : DeviceFlowStatus()

/**
* The device flow completed successfully and an access token has been obtained.
*
* @property user The authenticated user.
*/
data class Success(val user: User) : DeviceFlowStatus()

/**
* The device code expired before the user authorized the request.
*/
data object Expired : DeviceFlowStatus()

/**
* The user explicitly denied the authorization request.
*/
data object AccessDenied : DeviceFlowStatus()

/**
* An unrecoverable error occurred during the device flow.
*
* @property exception The exception that caused the failure.
*/
data class Failure(val exception: Exception) : DeviceFlowStatus()
}
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,20 @@ class OidcClientConfig {
*/
lateinit var httpClient: HttpClient

/**
* Called once after OpenID discovery completes, allowing callers to patch any field
* on the discovered [OpenIdConfiguration] before it is used (e.g. override
* [OpenIdConfiguration.deviceAuthorizationEndpoint] for a non-standard server).
*
* Example:
* ```kotlin
* openIdOverride = {
* deviceAuthorizationEndpoint = "https://custom.example.com/as/device_authorization"
* }
* ```
*/
var openIdOverride: (OpenIdConfiguration.() -> Unit)? = null

/**
* Adds a scope to the set of scopes.
*
Expand All @@ -204,7 +218,7 @@ class OidcClientConfig {
tokenStorage = storage()
}
if (!::openId.isInitialized) {
openId = discover()
openId = discover().also { openIdOverride?.invoke(it) }
}
if (!::agent.isInitialized) {
updateAgent(DefaultAgent)
Expand Down Expand Up @@ -270,5 +284,6 @@ class OidcClientConfig {
this.additionalParameters = other.additionalParameters
this.httpClient = other.httpClient
this.par = other.par
this.openIdOverride = other.openIdOverride
}
}
Loading
Loading