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"])
}

@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())
}

@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 @@ -27,10 +27,24 @@ private const val COM_PING_SDK_V_1_TOKENS = "com.pingidentity.sdk.v1.tokens"
@PingDsl
class OidcClientConfig {

// Backing property for OpenID configuration to allow for lazy initialization
private var _openId: OpenIdConfiguration? = null

/**
* OpenID configuration.
*/
lateinit var openId: OpenIdConfiguration
var openId: OpenIdConfiguration
get() = _openId ?: throw UninitializedPropertyAccessException("property openId has not been initialized")
set(value) { _openId = value }

/**
* Configures the OpenID configuration directly.
*
* @param block A lambda to configure the [OpenIdConfiguration].
*/
fun openId(block: OpenIdConfiguration.() -> Unit) {
_openId = OpenIdConfiguration().apply(block)
}

/**
* Token refresh threshold in seconds.
Expand Down Expand Up @@ -203,7 +217,7 @@ class OidcClientConfig {
if (!::tokenStorage.isInitialized) {
tokenStorage = storage()
}
if (!::openId.isInitialized) {
if (_openId == null) {
openId = discover()
}
if (!::agent.isInitialized) {
Expand Down Expand Up @@ -248,7 +262,7 @@ class OidcClientConfig {
* @param other The other configuration to merge.
*/
operator fun plusAssign(other: OidcClientConfig) {
this.openId = other.openId
this.openId = other.openId.copy()
this.refreshThreshold = other.refreshThreshold
this.agent = other.agent
this.logger = other.logger
Expand Down
Loading
Loading