From c89daa81788b5e3fc9432ebe7bc6642589471ee2 Mon Sep 17 00:00:00 2001 From: Andy Witrisna Date: Mon, 11 May 2026 09:20:19 -0700 Subject: [PATCH] SDKS-4784 Implement OAuth 2.0 Device Authorization Grant (RFC 8628) support - Implement `OidcDeviceClient` to manage the device authorization flow, including requesting device codes and polling the token endpoint. - Add `DeviceFlowStatus` sealed class to represent the states of the device flow: `Started`, `Polling`, `Success`, `Expired`, `AccessDenied`, and `Failure`. - Update `Oidc` module to detect the presence of `VERIFICATION_URI_COMPLETE` in the shared context to trigger device-code completion instead of the standard browser-redirect flow. - Modify `Journey` and `DaVinci` success logic to skip standard OIDC token exchange and instead perform device code verification when a `user_code` is present. - Implement `populateDeviceFlowVerificationRequest` to construct verification URLs supporting both standard and PingOne-specific tenant path formats. - Update `OpenIdConfiguration` to include `device_authorization_endpoint` and allow manual configuration via a new `openId` DSL block in `OidcClientConfig`. - Support passing custom parameters in `Journey.start` via an updated `Option` class. - Add comprehensive unit tests for device flow polling logic, error handling (e.g., `slow_down`, `expired_token`), and integration within DaVinci and Journey workflows. --- .gitignore | 1 + .../davinci/DaVinciTest.Response.kt | 3 +- .../com/pingidentity/davinci/DaVinciTest.kt | 202 ++++++++ .../kotlin/com/pingidentity/oidc/Constants.kt | 4 + .../oidc/DeviceAuthorizationResponse.kt | 37 ++ .../com/pingidentity/oidc/DeviceFlowStatus.kt | 58 +++ .../com/pingidentity/oidc/OidcClientConfig.kt | 19 +- .../com/pingidentity/oidc/OidcDeviceClient.kt | 272 ++++++++++ .../pingidentity/oidc/OpenIdConfiguration.kt | 17 +- .../com/pingidentity/oidc/module/Oidc.kt | 62 ++- .../pingidentity/oidc/module/OidcRequest.kt | 39 ++ .../pingidentity/oidc/DeviceFlowStatusTest.kt | 151 ++++++ .../pingidentity/oidc/OidcClientConfigTest.kt | 4 +- .../pingidentity/oidc/OidcDeviceClientTest.kt | 472 ++++++++++++++++++ .../oidc/OpenIdConfigurationTest.kt | 16 + .../kotlin/com/pingidentity/oidc/Response.kt | 13 + .../com/pingidentity/journey/Constants.kt | 6 +- .../com/pingidentity/journey/Journey.kt | 17 +- .../com/pingidentity/journey/module/Oidc.kt | 48 +- .../journey/JourneyTest.Response.kt | 3 +- .../com/pingidentity/journey/JourneyTest.kt | 206 ++++++++ 21 files changed, 1611 insertions(+), 39 deletions(-) create mode 100644 foundation/oidc/src/main/kotlin/com/pingidentity/oidc/DeviceAuthorizationResponse.kt create mode 100644 foundation/oidc/src/main/kotlin/com/pingidentity/oidc/DeviceFlowStatus.kt create mode 100644 foundation/oidc/src/main/kotlin/com/pingidentity/oidc/OidcDeviceClient.kt create mode 100644 foundation/oidc/src/test/kotlin/com/pingidentity/oidc/DeviceFlowStatusTest.kt create mode 100644 foundation/oidc/src/test/kotlin/com/pingidentity/oidc/OidcDeviceClientTest.kt diff --git a/.gitignore b/.gitignore index 7f62a738..b2b34d5d 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ local.properties xcuserdata .kotlin .polaris/ +.claude/settings.local.json diff --git a/davinci/src/test/kotlin/com/pingidentity/davinci/DaVinciTest.Response.kt b/davinci/src/test/kotlin/com/pingidentity/davinci/DaVinciTest.Response.kt index bfb4650b..32be3c87 100644 --- a/davinci/src/test/kotlin/com/pingidentity/davinci/DaVinciTest.Response.kt +++ b/davinci/src/test/kotlin/com/pingidentity/davinci/DaVinciTest.Response.kt @@ -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" } """, ) diff --git a/davinci/src/test/kotlin/com/pingidentity/davinci/DaVinciTest.kt b/davinci/src/test/kotlin/com/pingidentity/davinci/DaVinciTest.kt index e2ac7235..34404c9a 100644 --- a/davinci/src/test/kotlin/com/pingidentity/davinci/DaVinciTest.kt +++ b/davinci/src/test/kotlin/com/pingidentity/davinci/DaVinciTest.kt @@ -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 @@ -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 @@ -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 = @@ -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() + 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() + 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() + + 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 = """ { diff --git a/foundation/oidc/src/main/kotlin/com/pingidentity/oidc/Constants.kt b/foundation/oidc/src/main/kotlin/com/pingidentity/oidc/Constants.kt index fd4bcef0..3aafe9bb 100644 --- a/foundation/oidc/src/main/kotlin/com/pingidentity/oidc/Constants.kt +++ b/foundation/oidc/src/main/kotlin/com/pingidentity/oidc/Constants.kt @@ -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" } \ No newline at end of file diff --git a/foundation/oidc/src/main/kotlin/com/pingidentity/oidc/DeviceAuthorizationResponse.kt b/foundation/oidc/src/main/kotlin/com/pingidentity/oidc/DeviceAuthorizationResponse.kt new file mode 100644 index 00000000..624b57b9 --- /dev/null +++ b/foundation/oidc/src/main/kotlin/com/pingidentity/oidc/DeviceAuthorizationResponse.kt @@ -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, +) diff --git a/foundation/oidc/src/main/kotlin/com/pingidentity/oidc/DeviceFlowStatus.kt b/foundation/oidc/src/main/kotlin/com/pingidentity/oidc/DeviceFlowStatus.kt new file mode 100644 index 00000000..cec8fd60 --- /dev/null +++ b/foundation/oidc/src/main/kotlin/com/pingidentity/oidc/DeviceFlowStatus.kt @@ -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() +} diff --git a/foundation/oidc/src/main/kotlin/com/pingidentity/oidc/OidcClientConfig.kt b/foundation/oidc/src/main/kotlin/com/pingidentity/oidc/OidcClientConfig.kt index 4fd51ecc..e2672910 100644 --- a/foundation/oidc/src/main/kotlin/com/pingidentity/oidc/OidcClientConfig.kt +++ b/foundation/oidc/src/main/kotlin/com/pingidentity/oidc/OidcClientConfig.kt @@ -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. * @@ -204,7 +218,7 @@ class OidcClientConfig { tokenStorage = storage() } if (!::openId.isInitialized) { - openId = discover() + openId = discover().also { openIdOverride?.invoke(it) } } if (!::agent.isInitialized) { updateAgent(DefaultAgent) @@ -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 @@ -270,5 +284,6 @@ class OidcClientConfig { this.additionalParameters = other.additionalParameters this.httpClient = other.httpClient this.par = other.par + this.openIdOverride = other.openIdOverride } } \ No newline at end of file diff --git a/foundation/oidc/src/main/kotlin/com/pingidentity/oidc/OidcDeviceClient.kt b/foundation/oidc/src/main/kotlin/com/pingidentity/oidc/OidcDeviceClient.kt new file mode 100644 index 00000000..012c4f3f --- /dev/null +++ b/foundation/oidc/src/main/kotlin/com/pingidentity/oidc/OidcDeviceClient.kt @@ -0,0 +1,272 @@ +/* + * 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. + */ + +/** + * OAuth 2.0 Device Authorization Grant flow implementation (RFC 8628). + * + * Contains [OidcDeviceClient] and the factory function of the same name, along with the + * private types used to execute device-code token polling. + */ +package com.pingidentity.oidc + +import com.pingidentity.browser.BrowserCanceledException +import com.pingidentity.browser.BrowserLauncher.launch +import com.pingidentity.browser.BrowserLauncher.redirectUri +import com.pingidentity.exception.ApiException +import com.pingidentity.network.isSuccess +import com.pingidentity.oidc.Constants.CLIENT_ID +import com.pingidentity.oidc.Constants.DEVICE_CODE +import com.pingidentity.oidc.Constants.GRANT_TYPE +import com.pingidentity.oidc.Constants.SCOPE +import com.pingidentity.oidc.Constants.URN_DEVICE_CODE_GRANT_TYPE +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import java.net.URL + +// Error codes defined by RFC 8628 §3.5 +private const val ERROR_AUTHORIZATION_PENDING = "authorization_pending" +private const val ERROR_SLOW_DOWN = "slow_down" +private const val ERROR_EXPIRED_TOKEN = "expired_token" +private const val ERROR_ACCESS_DENIED = "access_denied" +private const val SLOW_DOWN_INCREMENT_SECONDS = 5 + +/** + * Factory function to create an [OidcDeviceClient] with the provided configuration block. + * + * Example: + * ```kotlin + * val client = OidcDeviceClient { + * discoveryEndpoint = "https://auth.example.com/.well-known/openid-configuration" + * clientId = "my-client-id" + * scopes = mutableSetOf("openid", "profile") + * } + * ``` + * + * @param block Configuration block applied to [OidcClientConfig]. + * @return A configured [OidcDeviceClient] instance. + */ +fun OidcDeviceClient(block: OidcClientConfig.() -> Unit = {}): OidcDeviceClient { + val config = OidcClientConfig().apply(block) + return OidcDeviceClient(config) +} + +/** + * OAuth 2.0 Device Authorization Grant client (RFC 8628). + * + * This client handles the requesting-device side of the Device Authorization flow: + * 1. Requests a device code from the authorization server. + * 2. Polls the token endpoint until the user approves, the code expires, or an error occurs. + * 3. Optionally opens the verification URI in a browser tab for the user's convenience. + * + * @property config The configuration for this client. + */ +class OidcDeviceClient(internal val config: OidcClientConfig) { + + /** + * Starts the device authorization flow and polls for the token. + * + * Emits [DeviceFlowStatus.Started] immediately after obtaining the device code, then emits + * [DeviceFlowStatus.Polling] on each poll interval until one of the terminal states is reached: + * - [DeviceFlowStatus.Success] — access token received and stored. + * - [DeviceFlowStatus.Expired] — device code expired or access was denied. + * - [DeviceFlowStatus.Failure] — unrecoverable error occurred. + * + * The flow closes automatically after emitting any terminal state. + */ + fun deviceAuthorization(): Flow = flow { + val logger = config.logger + try { + config.init() + logger.i("Starting device authorization flow") + + val deviceAuthEndpoint = config.openId.deviceAuthorizationEndpoint + if (deviceAuthEndpoint.isEmpty()) { + emit(DeviceFlowStatus.Failure(IllegalStateException("device_authorization_endpoint is not configured"))) + return@flow + } + + val deviceAuthResponse = requestDeviceAuthorization(deviceAuthEndpoint) + emit(DeviceFlowStatus.Started(deviceAuthResponse)) + + val expiresAt = System.currentTimeMillis() + (deviceAuthResponse.expiresIn * 1000L) + var pollInterval = deviceAuthResponse.interval + var pollCount = 0 + + while (System.currentTimeMillis() < expiresAt) { + delay(pollInterval * 1000L) + + if (System.currentTimeMillis() >= expiresAt) { + logger.i("Device code expired (wall-clock)") + emit(DeviceFlowStatus.Expired) + return@flow + } + + pollCount++ + logger.i("Polling token endpoint (attempt $pollCount)") + + val tokenResponse = + pollTokenEndpoint(deviceAuthResponse.deviceCode, config.openId.tokenEndpoint) + + when { + tokenResponse.isSuccess -> { + val token = json.decodeFromString(tokenResponse.body) + config.tokenStorage.save(token) + logger.i("Device flow succeeded") + emit(DeviceFlowStatus.Success(OidcUser(config))) + return@flow + } + + tokenResponse.error == ERROR_AUTHORIZATION_PENDING -> { + val nextPollAt = System.currentTimeMillis() + (pollInterval * 1000L) + emit(DeviceFlowStatus.Polling(pollCount, pollInterval, nextPollAt)) + } + + tokenResponse.error == ERROR_SLOW_DOWN -> { + // RFC 8628 §3.5: increase interval by 5 s on each slow_down response. + pollInterval += SLOW_DOWN_INCREMENT_SECONDS + logger.i("Slow down received; new interval: $pollInterval s") + val nextPollAt = System.currentTimeMillis() + (pollInterval * 1000L) + emit(DeviceFlowStatus.Polling(pollCount, pollInterval, nextPollAt)) + } + + tokenResponse.error == ERROR_EXPIRED_TOKEN -> { + logger.i("Device code expired") + emit(DeviceFlowStatus.Expired) + return@flow + } + + tokenResponse.error == ERROR_ACCESS_DENIED -> { + logger.i("Device flow access denied") + emit(DeviceFlowStatus.AccessDenied) + return@flow + } + + else -> { + val message = tokenResponse.error ?: "Unknown token endpoint error" + logger.w("Device flow failed: $message") + emit(DeviceFlowStatus.Failure(IllegalStateException(message))) + return@flow + } + } + } + + logger.i("Device code expired (polling loop exit)") + emit(DeviceFlowStatus.Expired) + } catch (e: Exception) { + // Re-check active state before emitting: if the coroutine was cancelled, re-throw + // so the Flow terminates with CancellationException rather than swallowing it. + logger.w("Device flow failed with exception: ${e.message}", e) + currentCoroutineContext().ensureActive() + emit(DeviceFlowStatus.Failure(e)) + } + } + + /** + * Opens [verificationUriComplete] in a browser tab (Custom Tab / Auth Tab) so the user can + * approve the device authorization request. + * + * This call is non-blocking with respect to the polling [Flow] — the caller should launch it + * in a separate coroutine. A [BrowserCanceledException] is silently + * swallowed because tab dismissal is an expected user action. Any other exception is + * propagated to the caller. + * + * @param verificationUriComplete The verification URI (typically contains the user code as a + * query parameter for pre-filled approval). + */ + suspend fun authorize(verificationUriComplete: String) { + try { + // The redirectUri is not actually used in the device flow + launch(URL(verificationUriComplete), redirectUri) + } catch (_: BrowserCanceledException) { + // Tab dismissal is an expected user action — don't surface it as an error. + config.logger.d("Browser tab dismissed (expected for device flow)") + } + } + + /** + * Returns the authenticated [User] if a valid token is present in storage, or null otherwise. + */ + suspend fun user(): User? { + config.init() + return config.tokenStorage.get()?.let { OidcUser(config) } + } + + + private suspend fun requestDeviceAuthorization(endpoint: String): DeviceAuthorizationResponse { + val response = config.httpClient.request { + url = endpoint + form { + put(CLIENT_ID, config.clientId) + put(SCOPE, config.scopes.joinToString(" ")) + } + } + if (response.status.isSuccess()) { + return json.decodeFromString(response.body()) + } + throw ApiException( + response.status, + "Device authorization request failed: Response ${response.body()}" + ) + } + + /** + * Outcome of a single token-endpoint poll attempt. + * + * @property isSuccess `true` when the HTTP response status is 2xx and the body contains a token. + * @property body Raw response body; parsed as a [Token] on success or a [TokenErrorResponse] on failure. + * @property error The `error` field from an OAuth error response, or `null` on success. + */ + private data class TokenPollResult( + val isSuccess: Boolean, + val body: String, + val error: String?, + ) + + /** + * Sends a single token request to [tokenEndpoint] using the device-code grant type. + * + * @param deviceCode The `device_code` from the device authorization response. + * @param tokenEndpoint The token endpoint URL from the OpenID Connect discovery document. + * @return A [TokenPollResult] indicating success or the RFC 8628 error code. + */ + private suspend fun pollTokenEndpoint( + deviceCode: String, + tokenEndpoint: String + ): TokenPollResult { + val response = config.httpClient.request { + url = tokenEndpoint + form { + put(GRANT_TYPE, URN_DEVICE_CODE_GRANT_TYPE) + put(DEVICE_CODE, deviceCode) + put(CLIENT_ID, config.clientId) + } + } + val body = response.body() + return if (response.status.isSuccess()) { + TokenPollResult(isSuccess = true, body = body, error = null) + } else { + // Best-effort parse — if the body is not a valid error JSON the error field is null + // and the caller's else branch will surface it as an unknown error. + val error = runCatching { + json.decodeFromString(body).error + }.getOrNull() + TokenPollResult(isSuccess = false, body = body, error = error) + } + } +} + +/** + * Minimal error response to extract the `error` field from a failed token endpoint response. + */ +@kotlinx.serialization.Serializable +private data class TokenErrorResponse( + @kotlinx.serialization.SerialName("error") + val error: String? = null, +) diff --git a/foundation/oidc/src/main/kotlin/com/pingidentity/oidc/OpenIdConfiguration.kt b/foundation/oidc/src/main/kotlin/com/pingidentity/oidc/OpenIdConfiguration.kt index 0264c1e6..e70993ea 100644 --- a/foundation/oidc/src/main/kotlin/com/pingidentity/oidc/OpenIdConfiguration.kt +++ b/foundation/oidc/src/main/kotlin/com/pingidentity/oidc/OpenIdConfiguration.kt @@ -19,21 +19,24 @@ import kotlinx.serialization.Serializable * @property endSessionEndpoint The URL of the end session endpoint. * @property pingEndIdpSessionEndpoint The URL of the end session endpoint with just using idToken * @property revocationEndpoint The URL of the revocation endpoint. + * @property deviceAuthorizationEndpoint The URL of the device authorization endpoint (RFC 8628). */ @Serializable data class OpenIdConfiguration( @SerialName("authorization_endpoint") - val authorizationEndpoint: String = "", + var authorizationEndpoint: String = "", @SerialName("pushed_authorization_request_endpoint") - val pushAuthorizationRequestEndpoint: String = "", + var pushAuthorizationRequestEndpoint: String = "", @SerialName("token_endpoint") - val tokenEndpoint: String = "", + var tokenEndpoint: String = "", @SerialName("userinfo_endpoint") - val userinfoEndpoint: String = "", + var userinfoEndpoint: String = "", @SerialName("end_session_endpoint") - val endSessionEndpoint: String = "", + var endSessionEndpoint: String = "", @SerialName("ping_end_idp_session_endpoint") - val pingEndIdpSessionEndpoint: String = "", + var pingEndIdpSessionEndpoint: String = "", @SerialName("revocation_endpoint") - val revocationEndpoint: String = "", + var revocationEndpoint: String = "", + @SerialName("device_authorization_endpoint") + var deviceAuthorizationEndpoint: String = "", ) \ No newline at end of file diff --git a/foundation/oidc/src/main/kotlin/com/pingidentity/oidc/module/Oidc.kt b/foundation/oidc/src/main/kotlin/com/pingidentity/oidc/module/Oidc.kt index b8f53da1..0894d4bd 100644 --- a/foundation/oidc/src/main/kotlin/com/pingidentity/oidc/module/Oidc.kt +++ b/foundation/oidc/src/main/kotlin/com/pingidentity/oidc/module/Oidc.kt @@ -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. @@ -7,10 +7,12 @@ package com.pingidentity.oidc.module +import android.net.Uri import com.pingidentity.oidc.Agent import com.pingidentity.oidc.AuthCode import com.pingidentity.oidc.Constants.CLIENT_ID import com.pingidentity.oidc.Constants.ID_TOKEN_HINT +import com.pingidentity.oidc.Constants.USER_CODE import com.pingidentity.oidc.DefaultAgent import com.pingidentity.oidc.OidcClient import com.pingidentity.oidc.OidcClientConfig @@ -20,6 +22,7 @@ import com.pingidentity.oidc.Pkce import com.pingidentity.oidc.exception.AuthorizeException import com.pingidentity.orchestrate.Module import com.pingidentity.orchestrate.Session +import com.pingidentity.orchestrate.SharedContext import com.pingidentity.orchestrate.SuccessNode /** @@ -29,6 +32,30 @@ private const val PKCE = "com.pingidentity.oidc.PKCE" private const val OIDC_CONFIG = "com.pingidentity.oidc.OidcClientConfig" internal const val PARAMETERS = "com.pingidentity.oidc.PARAMETERS" +/** + * Constant key used to pass the verification URI complete for the OAuth 2.0 Device Authorization + * Grant flow (RFC 8628). When this key is present in the flow context, the [Oidc] module will + * POST the user code to the device authorization endpoint after successful authentication. + * + * DaVinci callers should use the re-exported constant from `com.pingidentity.davinci.module`: + * ```kotlin + * daVinci.start { + * VERIFICATION_URI_COMPLETE to "https://example.com/device?user_code=WDJB-MJHT".toUri() + * } + * ``` + */ +const val VERIFICATION_URI_COMPLETE = "com.pingidentity.oidc.VERIFICATION_URI_COMPLETE" + +/** + * Returns the `user_code` query parameter from the [VERIFICATION_URI_COMPLETE] URI stored in + * this context, or `null` if the URI is absent or carries no `user_code`. + * + * A non-null return value signals that the current flow is a device-code completion flow and + * that the normal browser-redirect authorization step should be skipped. + */ +fun SharedContext.deviceUserCode(): String? = + getValue(VERIFICATION_URI_COMPLETE)?.getQueryParameter(USER_CODE) + /** * Oidc module for Workflow engine */ @@ -53,24 +80,29 @@ val Oidc = } start { request -> - // When user starting the flow again, revoke previous token if exists - workflow.oidcUser().revoke() - - val pkce = Pkce.generate() - flowContext[PKCE] = pkce - - val parameters = flowContext.getValue>(PARAMETERS) ?: emptyMap() - config.populateRequest(request, parameters, pkce) + flowContext.deviceUserCode()?.let { userCode -> + logger.d("Oidc: device code completion flow detected, skipping authorization request") + config.populateDeviceFlowVerificationRequest(request, userCode) + } ?: run { + // Revoke any existing tokens so the new flow starts with a clean state. + workflow.oidcUser().revoke() + val pkce = Pkce.generate() + // Stash PKCE in the flow context so the success handler can retrieve it for token exchange. + flowContext[PKCE] = pkce + val parameters = flowContext.getValue>(PARAMETERS) ?: emptyMap() + config.populateRequest(request, parameters, pkce) + } } success { success -> - val clone = - config.clone().also { - it.updateAgent(agent(success.session, flowContext[PKCE] as Pkce)) - } + // Device-code completion flow: token exchange already handled externally, skip. + flowContext.deviceUserCode()?.let { + return@success success + } + val pkce = flowContext[PKCE] as? Pkce + val clone = config.clone().also { it.updateAgent(agent(success.session, pkce)) } val user = OidcUser(clone).also { - // Fetch the access token from the session, ignore the result, still consider - // the user logged in + // token() result is intentionally ignored — user is considered logged in regardless it.token() } SuccessNode(success.input, workflow.prepareUser(user, success.session)) diff --git a/foundation/oidc/src/main/kotlin/com/pingidentity/oidc/module/OidcRequest.kt b/foundation/oidc/src/main/kotlin/com/pingidentity/oidc/module/OidcRequest.kt index 35dcee9c..f9d9a759 100644 --- a/foundation/oidc/src/main/kotlin/com/pingidentity/oidc/module/OidcRequest.kt +++ b/foundation/oidc/src/main/kotlin/com/pingidentity/oidc/module/OidcRequest.kt @@ -25,6 +25,7 @@ import com.pingidentity.oidc.Constants.RESPONSE_TYPE import com.pingidentity.oidc.Constants.SCOPE import com.pingidentity.oidc.Constants.STATE import com.pingidentity.oidc.Constants.UI_LOCATES +import com.pingidentity.oidc.Constants.USER_CODE_CAMEL import com.pingidentity.oidc.OidcClientConfig import com.pingidentity.oidc.Pkce import com.pingidentity.oidc.exception.AuthorizeException @@ -134,3 +135,41 @@ val populateRequest: suspend OidcClientConfig.(Request, Map, Pkc } request } + +private const val AS_DEVICE_AUTHORIZATION_PATH = "/as/device_authorization" + +/** + * Populates a request to verify a user code in the Device Authorization Grant flow (RFC 8628). + * + * **This function applies to DaVinci Environment only.** PingOne DaVinci uses + * specific URL to handle the device grant flow. + * + * Constructs the device flow verification URL from [com.pingidentity.oidc.OpenIdConfiguration.deviceAuthorizationEndpoint] + * by stripping the `/as/device_authorization` suffix to obtain the base URL, then appending + * `/applications/{clientId}/deviceFlow` with the `userCode` query parameter. + * + * Examples: + * - `https://auth.pingone.ca/{tenantId}/as/device_authorization` + * → `https://auth.pingone.ca/{tenantId}/applications/{clientId}/deviceFlow?userCode={userCode}` + * - `https://pingone.petrov.ca/as/device_authorization` + * → `https://pingone.petrov.ca/applications/{clientId}/deviceFlow?userCode={userCode}` + * + * @param userCode The user code obtained from the device authorization response that needs to be verified. + * @return The populated [Request] ready for execution. + */ +val populateDeviceFlowVerificationRequest: suspend OidcClientConfig.(Request, String) -> Request = + { request, userCode -> + val deviceAuthEndpoint = openId.deviceAuthorizationEndpoint + + // Strip "/as/device_authorization" to obtain the tenant-scoped base URL. + val baseUrl = deviceAuthEndpoint.removeSuffix(AS_DEVICE_AUTHORIZATION_PATH) + + request.url = "$baseUrl/applications/$clientId/deviceFlow" + + // PingOne format paths look like "/{tenantId}/as/device_authorization", whereas + // custom-domain paths look like "/as/device_authorization". + request.parameter(USER_CODE_CAMEL, userCode) + + request + } + diff --git a/foundation/oidc/src/test/kotlin/com/pingidentity/oidc/DeviceFlowStatusTest.kt b/foundation/oidc/src/test/kotlin/com/pingidentity/oidc/DeviceFlowStatusTest.kt new file mode 100644 index 00000000..8b31843a --- /dev/null +++ b/foundation/oidc/src/test/kotlin/com/pingidentity/oidc/DeviceFlowStatusTest.kt @@ -0,0 +1,151 @@ +/* + * 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.json.Json +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +class DeviceFlowStatusTest { + + // ----- DeviceAuthorizationResponse JSON round-trip ----- + + @Test + fun `DeviceAuthorizationResponse deserializes all fields from JSON`() { + val json = """ + { + "device_code": "GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS", + "user_code": "WDJB-MJHT", + "verification_uri": "https://example.com/device", + "verification_uri_complete": "https://example.com/device?user_code=WDJB-MJHT", + "expires_in": 1800, + "interval": 5 + } + """.trimIndent() + + val response = Json.decodeFromString(json) + + assertEquals("GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS", response.deviceCode) + assertEquals("WDJB-MJHT", response.userCode) + assertEquals("https://example.com/device", response.verificationUri) + assertEquals("https://example.com/device?user_code=WDJB-MJHT", response.verificationUriComplete) + assertEquals(1800, response.expiresIn) + assertEquals(5, response.interval) + } + + @Test + fun `DeviceAuthorizationResponse verificationUriComplete is null when absent`() { + val json = """ + { + "device_code": "abc123", + "user_code": "ABCD-1234", + "verification_uri": "https://example.com/activate", + "expires_in": 900 + } + """.trimIndent() + + val response = Json.decodeFromString(json) + + assertNull(response.verificationUriComplete) + } + + @Test + fun `DeviceAuthorizationResponse interval defaults to 5 when absent`() { + val json = """ + { + "device_code": "abc123", + "user_code": "ABCD-1234", + "verification_uri": "https://example.com/activate", + "expires_in": 900 + } + """.trimIndent() + + val response = Json.decodeFromString(json) + + assertEquals(5, response.interval) + } + + // ----- DeviceFlowStatus state construction ----- + + @Test + fun `DeviceFlowStatus Started holds DeviceAuthorizationResponse`() { + val response = DeviceAuthorizationResponse( + deviceCode = "dev-code", + userCode = "USER-CODE", + verificationUri = "https://example.com/device", + expiresIn = 1800, + ) + val status = DeviceFlowStatus.Started(response) + + assertTrue(status is DeviceFlowStatus.Started) + assertEquals("USER-CODE", status.response.userCode) + assertEquals("https://example.com/device", status.response.verificationUri) + } + + @Test + fun `DeviceFlowStatus Polling holds pollCount, pollInterval, nextPollAt`() { + val nextPollAt = System.currentTimeMillis() + 5000L + val status = DeviceFlowStatus.Polling( + pollCount = 3, + pollInterval = 5, + nextPollAt = nextPollAt, + ) + + assertTrue(status is DeviceFlowStatus.Polling) + assertEquals(3, status.pollCount) + assertEquals(5, status.pollInterval) + assertEquals(nextPollAt, status.nextPollAt) + } + + @Test + fun `DeviceFlowStatus Expired is a singleton data object`() { + val a = DeviceFlowStatus.Expired + val b = DeviceFlowStatus.Expired + assertTrue(a === b) + } + + @Test + fun `DeviceFlowStatus Failure holds exception`() { + val exception = RuntimeException("something went wrong") + val status = DeviceFlowStatus.Failure(exception) + + assertTrue(status is DeviceFlowStatus.Failure) + assertEquals("something went wrong", status.exception.message) + } + + @Test + fun `DeviceFlowStatus has exactly five states`() { + val response = DeviceAuthorizationResponse( + deviceCode = "d", + userCode = "u", + verificationUri = "https://example.com", + expiresIn = 300, + ) + val statuses: List = listOf( + DeviceFlowStatus.Started(response), + DeviceFlowStatus.Polling(1, 5, System.currentTimeMillis()), + DeviceFlowStatus.Expired, + DeviceFlowStatus.AccessDenied, + DeviceFlowStatus.Failure(RuntimeException("err")), + ) + // Success requires a User — verified by type check only + val sealed = DeviceFlowStatus::class.sealedSubclasses + assertEquals(6, sealed.size) + assertNotNull(sealed.firstOrNull { it.simpleName == "Started" }) + assertNotNull(sealed.firstOrNull { it.simpleName == "Polling" }) + assertNotNull(sealed.firstOrNull { it.simpleName == "Success" }) + assertNotNull(sealed.firstOrNull { it.simpleName == "Expired" }) + assertNotNull(sealed.firstOrNull { it.simpleName == "AccessDenied" }) + assertNotNull(sealed.firstOrNull { it.simpleName == "Failure" }) + // Silence unused warning + assertEquals(5, statuses.size) + } +} diff --git a/foundation/oidc/src/test/kotlin/com/pingidentity/oidc/OidcClientConfigTest.kt b/foundation/oidc/src/test/kotlin/com/pingidentity/oidc/OidcClientConfigTest.kt index 24e7a23e..d086d0a1 100644 --- a/foundation/oidc/src/test/kotlin/com/pingidentity/oidc/OidcClientConfigTest.kt +++ b/foundation/oidc/src/test/kotlin/com/pingidentity/oidc/OidcClientConfigTest.kt @@ -209,9 +209,9 @@ class OidcClientConfigTest { httpClient = mockk() } - //Ensure there are 22 properties in the class for now. + //Ensure there are 23 properties in the class for now. val clazz: KClass = OidcClientConfig::class - assertEquals(clazz.memberProperties.size, 22) + assertEquals(clazz.memberProperties.size, 23) val clonedConfig = oidcClientConfig.clone() diff --git a/foundation/oidc/src/test/kotlin/com/pingidentity/oidc/OidcDeviceClientTest.kt b/foundation/oidc/src/test/kotlin/com/pingidentity/oidc/OidcDeviceClientTest.kt new file mode 100644 index 00000000..84086426 --- /dev/null +++ b/foundation/oidc/src/test/kotlin/com/pingidentity/oidc/OidcDeviceClientTest.kt @@ -0,0 +1,472 @@ +/* + * 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 com.pingidentity.network.ktor.KtorHttpClient +import com.pingidentity.storage.MemoryStorage +import io.ktor.client.HttpClient +import io.ktor.client.engine.mock.MockEngine +import io.ktor.client.engine.mock.respond +import io.ktor.client.request.forms.FormDataContent +import io.ktor.http.HttpStatusCode +import io.ktor.utils.io.ByteReadChannel +import kotlinx.coroutines.flow.toList +import kotlinx.coroutines.test.runTest +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +class OidcDeviceClientTest { + + private lateinit var mockEngine: MockEngine + + private val deviceAuthResponseJson = """ + { + "device_code": "test-device-code", + "user_code": "ABCD-1234", + "verification_uri": "https://example.com/device", + "verification_uri_complete": "https://example.com/device?user_code=ABCD-1234", + "expires_in": 1800, + "interval": 0 + } + """.trimIndent() + + private val tokenResponseJson = """ + { + "access_token": "test-access-token", + "token_type": "Bearer", + "scope": "openid", + "expires_in": 3600 + } + """.trimIndent() + + private fun pendingResponse() = + ByteReadChannel("""{"error":"authorization_pending","error_description":"The user has not yet approved the request."}""") + + private fun slowDownResponse() = + ByteReadChannel("""{"error":"slow_down","error_description":"Slow down"}""") + + private fun expiredTokenResponse() = + ByteReadChannel("""{"error":"expired_token","error_description":"The device code has expired."}""") + + private fun accessDeniedResponse() = + ByteReadChannel("""{"error":"access_denied","error_description":"The user denied the request."}""") + + private fun unknownErrorResponse() = + ByteReadChannel("""{"error":"some_unknown_error","error_description":"Oops"}""") + + @BeforeTest + fun setUp() { + mockEngine = MockEngine { request -> + when (request.url.encodedPath) { + "/openid-configuration" -> { + respond(openIdConfigurationWithDeviceEndpointResponse(), HttpStatusCode.OK, headers) + } + "/device_authorization" -> { + respond(ByteReadChannel(deviceAuthResponseJson), HttpStatusCode.OK, headers) + } + "/token" -> { + respond(ByteReadChannel(tokenResponseJson), HttpStatusCode.OK, headers) + } + else -> { + respond(ByteReadChannel(""), HttpStatusCode.InternalServerError) + } + } + } + } + + @AfterTest + fun tearDown() { + mockEngine.close() + } + + // ------------------------------------------------------------------ + // Factory function compilation test + // ------------------------------------------------------------------ + + @Test + fun `OidcDeviceClient factory creates instance without redirectUri`() = runTest { + val client = OidcDeviceClient { + discoveryEndpoint = "http://localhost/openid-configuration" + clientId = "test-client" + scopes = mutableSetOf("openid") + httpClient = KtorHttpClient(HttpClient(mockEngine)) + storage = { MemoryStorage() } + } + assertNotNull(client) + } + + // ------------------------------------------------------------------ + // Started emission + // ------------------------------------------------------------------ + + @Test + fun `deviceAuthorization emits Started with non-empty userCode and verificationUri`() = runTest { + val client = OidcDeviceClient { + discoveryEndpoint = "http://localhost/openid-configuration" + clientId = "test-client" + scopes = mutableSetOf("openid") + httpClient = KtorHttpClient(HttpClient(mockEngine)) + storage = { MemoryStorage() } + } + + val statuses = client.deviceAuthorization().toList() + + val started = statuses.first() + assertIs(started) + assertTrue(started.response.userCode.isNotEmpty()) + assertTrue(started.response.verificationUri.isNotEmpty()) + assertEquals("ABCD-1234", started.response.userCode) + assertEquals("https://example.com/device", started.response.verificationUri) + } + + // ------------------------------------------------------------------ + // Success path + // ------------------------------------------------------------------ + + @Test + fun `deviceAuthorization emits Success and user() returns the stored User`() = runTest { + val storage = MemoryStorage() + val client = OidcDeviceClient { + discoveryEndpoint = "http://localhost/openid-configuration" + clientId = "test-client" + scopes = mutableSetOf("openid") + httpClient = KtorHttpClient(HttpClient(mockEngine)) + this.storage = { storage } + } + + val statuses = client.deviceAuthorization().toList() + + val success = statuses.last() + assertIs(success) + assertNotNull(success.user) + + // user() should return the stored user + val user = client.user() + assertNotNull(user) + } + + // ------------------------------------------------------------------ + // Polling: authorization_pending + // ------------------------------------------------------------------ + + @Test + fun `deviceAuthorization emits Polling with incrementing pollCount while authorization_pending`() = runTest { + var tokenCallCount = 0 + val pendingThenSuccessEngine = MockEngine { request -> + when (request.url.encodedPath) { + "/openid-configuration" -> respond(openIdConfigurationWithDeviceEndpointResponse(), HttpStatusCode.OK, headers) + "/device_authorization" -> respond(ByteReadChannel(deviceAuthResponseJson), HttpStatusCode.OK, headers) + "/token" -> { + tokenCallCount++ + if (tokenCallCount < 3) { + respond(pendingResponse(), HttpStatusCode.BadRequest, headers) + } else { + respond(ByteReadChannel(tokenResponseJson), HttpStatusCode.OK, headers) + } + } + else -> respond(ByteReadChannel(""), HttpStatusCode.InternalServerError) + } + } + + val client = OidcDeviceClient { + discoveryEndpoint = "http://localhost/openid-configuration" + clientId = "test-client" + scopes = mutableSetOf("openid") + httpClient = KtorHttpClient(HttpClient(pendingThenSuccessEngine)) + storage = { MemoryStorage() } + } + + val statuses = client.deviceAuthorization().toList() + + val pollingStatuses = statuses.filterIsInstance() + assertEquals(2, pollingStatuses.size) + assertEquals(1, pollingStatuses[0].pollCount) + assertEquals(2, pollingStatuses[1].pollCount) + + assertIs(statuses.last()) + pendingThenSuccessEngine.close() + } + + // ------------------------------------------------------------------ + // Expired: expired_token + // ------------------------------------------------------------------ + + @Test + fun `deviceAuthorization emits Expired when expired_token is returned`() = runTest { + val expiredEngine = MockEngine { request -> + when (request.url.encodedPath) { + "/openid-configuration" -> respond(openIdConfigurationWithDeviceEndpointResponse(), HttpStatusCode.OK, headers) + "/device_authorization" -> respond(ByteReadChannel(deviceAuthResponseJson), HttpStatusCode.OK, headers) + "/token" -> respond(expiredTokenResponse(), HttpStatusCode.BadRequest, headers) + else -> respond(ByteReadChannel(""), HttpStatusCode.InternalServerError) + } + } + + val client = OidcDeviceClient { + discoveryEndpoint = "http://localhost/openid-configuration" + clientId = "test-client" + scopes = mutableSetOf("openid") + httpClient = KtorHttpClient(HttpClient(expiredEngine)) + storage = { MemoryStorage() } + } + + val statuses = client.deviceAuthorization().toList() + + assertIs(statuses.last()) + expiredEngine.close() + } + + // ------------------------------------------------------------------ + // AccessDenied: access_denied + // ------------------------------------------------------------------ + + @Test + fun `deviceAuthorization emits AccessDenied when access_denied is returned`() = runTest { + val accessDeniedEngine = MockEngine { request -> + when (request.url.encodedPath) { + "/openid-configuration" -> respond(openIdConfigurationWithDeviceEndpointResponse(), HttpStatusCode.OK, headers) + "/device_authorization" -> respond(ByteReadChannel(deviceAuthResponseJson), HttpStatusCode.OK, headers) + "/token" -> respond(accessDeniedResponse(), HttpStatusCode.BadRequest, headers) + else -> respond(ByteReadChannel(""), HttpStatusCode.InternalServerError) + } + } + + val client = OidcDeviceClient { + discoveryEndpoint = "http://localhost/openid-configuration" + clientId = "test-client" + scopes = mutableSetOf("openid") + httpClient = KtorHttpClient(HttpClient(accessDeniedEngine)) + storage = { MemoryStorage() } + } + + val statuses = client.deviceAuthorization().toList() + + assertIs(statuses.last()) + accessDeniedEngine.close() + } + + // ------------------------------------------------------------------ + // Failure: unknown error + // ------------------------------------------------------------------ + + @Test + fun `deviceAuthorization emits Failure on unknown server error`() = runTest { + val errorEngine = MockEngine { request -> + when (request.url.encodedPath) { + "/openid-configuration" -> respond(openIdConfigurationWithDeviceEndpointResponse(), HttpStatusCode.OK, headers) + "/device_authorization" -> respond(ByteReadChannel(deviceAuthResponseJson), HttpStatusCode.OK, headers) + "/token" -> respond(unknownErrorResponse(), HttpStatusCode.BadRequest, headers) + else -> respond(ByteReadChannel(""), HttpStatusCode.InternalServerError) + } + } + + val client = OidcDeviceClient { + discoveryEndpoint = "http://localhost/openid-configuration" + clientId = "test-client" + scopes = mutableSetOf("openid") + httpClient = KtorHttpClient(HttpClient(errorEngine)) + storage = { MemoryStorage() } + } + + val statuses = client.deviceAuthorization().toList() + + val failure = statuses.last() + assertIs(failure) + assertTrue(failure.exception.message?.contains("some_unknown_error") == true) + errorEngine.close() + } + + // ------------------------------------------------------------------ + // slow_down: interval increases by 5 seconds + // ------------------------------------------------------------------ + + @Test + fun `deviceAuthorization increases interval by 5 seconds on slow_down`() = runTest { + var tokenCallCount = 0 + val slowDownEngine = MockEngine { request -> + when (request.url.encodedPath) { + "/openid-configuration" -> respond(openIdConfigurationWithDeviceEndpointResponse(), HttpStatusCode.OK, headers) + "/device_authorization" -> respond(ByteReadChannel(deviceAuthResponseJson), HttpStatusCode.OK, headers) + "/token" -> { + tokenCallCount++ + if (tokenCallCount == 1) { + respond(slowDownResponse(), HttpStatusCode.BadRequest, headers) + } else { + respond(ByteReadChannel(tokenResponseJson), HttpStatusCode.OK, headers) + } + } + else -> respond(ByteReadChannel(""), HttpStatusCode.InternalServerError) + } + } + + val client = OidcDeviceClient { + discoveryEndpoint = "http://localhost/openid-configuration" + clientId = "test-client" + scopes = mutableSetOf("openid") + httpClient = KtorHttpClient(HttpClient(slowDownEngine)) + storage = { MemoryStorage() } + } + + val statuses = client.deviceAuthorization().toList() + + // The Polling state emitted after slow_down must show interval = 0 (base) + 5 = 5 + val polling = statuses.filterIsInstance() + assertEquals(1, polling.size) + assertEquals(5, polling[0].pollInterval) + + assertIs(statuses.last()) + slowDownEngine.close() + } + + // ------------------------------------------------------------------ + // openId { deviceAuthorizationEndpoint } override + // ------------------------------------------------------------------ + + @Test + fun `OidcDeviceClient uses openIdOverride for deviceAuthorizationEndpoint`() = runTest { + val overrideEngine = MockEngine { request -> + when (request.url.encodedPath) { + "/openid-configuration" -> respond(openIdConfigurationResponse(), HttpStatusCode.OK, headers) + "/custom-device" -> respond(ByteReadChannel(deviceAuthResponseJson), HttpStatusCode.OK, headers) + "/token" -> respond(ByteReadChannel(tokenResponseJson), HttpStatusCode.OK, headers) + else -> respond(ByteReadChannel(""), HttpStatusCode.InternalServerError) + } + } + + val client = OidcDeviceClient { + discoveryEndpoint = "http://localhost/openid-configuration" + clientId = "test-client" + scopes = mutableSetOf("openid") + httpClient = KtorHttpClient(HttpClient(overrideEngine)) + storage = { MemoryStorage() } + openIdOverride = { + deviceAuthorizationEndpoint = "http://localhost/custom-device" + } + } + + val statuses = client.deviceAuthorization().toList() + + assertIs(statuses.first()) + assertIs(statuses.last()) + + // Verify the custom device endpoint was called + val deviceRequest = overrideEngine.requestHistory.first { it.url.encodedPath == "/custom-device" } + assertNotNull(deviceRequest) + overrideEngine.close() + } + + // ------------------------------------------------------------------ + // Missing device authorization endpoint + // ------------------------------------------------------------------ + + @Test + fun `deviceAuthorization emits Failure when deviceAuthorizationEndpoint is empty`() = runTest { + val noEndpointEngine = MockEngine { request -> + when (request.url.encodedPath) { + "/openid-configuration" -> respond(openIdConfigurationResponse(), HttpStatusCode.OK, headers) // no device endpoint + else -> respond(ByteReadChannel(""), HttpStatusCode.InternalServerError) + } + } + + val client = OidcDeviceClient { + discoveryEndpoint = "http://localhost/openid-configuration" + clientId = "test-client" + scopes = mutableSetOf("openid") + httpClient = KtorHttpClient(HttpClient(noEndpointEngine)) + storage = { MemoryStorage() } + } + + val statuses = client.deviceAuthorization().toList() + + val failure = statuses.last() + assertIs(failure) + assertTrue(failure.exception.message?.contains("device_authorization_endpoint") == true) + noEndpointEngine.close() + } + + // ------------------------------------------------------------------ + // Device authorization HTTP error emits Failure (no exception thrown) + // ------------------------------------------------------------------ + + @Test + fun `deviceAuthorization emits Failure when device authorization request fails`() = runTest { + val serverErrorEngine = MockEngine { request -> + when (request.url.encodedPath) { + "/openid-configuration" -> respond(openIdConfigurationWithDeviceEndpointResponse(), HttpStatusCode.OK, headers) + "/device_authorization" -> respond(ByteReadChannel("server error"), HttpStatusCode.InternalServerError) + else -> respond(ByteReadChannel(""), HttpStatusCode.InternalServerError) + } + } + + val client = OidcDeviceClient { + discoveryEndpoint = "http://localhost/openid-configuration" + clientId = "test-client" + scopes = mutableSetOf("openid") + httpClient = KtorHttpClient(HttpClient(serverErrorEngine)) + storage = { MemoryStorage() } + } + + val statuses = client.deviceAuthorization().toList() + + assertIs(statuses.last()) + serverErrorEngine.close() + } + + // ------------------------------------------------------------------ + // user() returns null when no token is stored + // ------------------------------------------------------------------ + + @Test + fun `user returns null when no token is stored`() = runTest { + val client = OidcDeviceClient { + discoveryEndpoint = "http://localhost/openid-configuration" + clientId = "test-client" + scopes = mutableSetOf("openid") + httpClient = KtorHttpClient(HttpClient(mockEngine)) + storage = { MemoryStorage() } + } + + // Initialise config so storage is ready, but don't run deviceAuthorization() + client.config.init() + val user = client.user() + assertNull(user) + } + + // ------------------------------------------------------------------ + // Token endpoint request parameters + // ------------------------------------------------------------------ + + @Test + fun `deviceAuthorization sends correct form params to device_authorization endpoint`() = runTest { + val client = OidcDeviceClient { + discoveryEndpoint = "http://localhost/openid-configuration" + clientId = "my-client" + scopes = mutableSetOf("openid", "profile") + httpClient = KtorHttpClient(HttpClient(mockEngine)) + storage = { MemoryStorage() } + } + + client.deviceAuthorization().toList() + + // Request 0 = discovery, request 1 = device_authorization + val deviceAuthRequest = mockEngine.requestHistory[1] + assertEquals("/device_authorization", deviceAuthRequest.url.encodedPath) + val formData = (deviceAuthRequest.body as FormDataContent).formData + assertEquals("my-client", formData["client_id"]) + assertTrue(formData["scope"]?.contains("openid") == true) + } +} diff --git a/foundation/oidc/src/test/kotlin/com/pingidentity/oidc/OpenIdConfigurationTest.kt b/foundation/oidc/src/test/kotlin/com/pingidentity/oidc/OpenIdConfigurationTest.kt index bf8077e9..d96a04a4 100644 --- a/foundation/oidc/src/test/kotlin/com/pingidentity/oidc/OpenIdConfigurationTest.kt +++ b/foundation/oidc/src/test/kotlin/com/pingidentity/oidc/OpenIdConfigurationTest.kt @@ -32,6 +32,7 @@ class OpenIdConfigurationTest { assertEquals("", config.endSessionEndpoint) assertEquals("", config.pingEndIdpSessionEndpoint) assertEquals("", config.revocationEndpoint) + assertEquals("", config.deviceAuthorizationEndpoint) } @TestRailCase(22107) @@ -79,5 +80,20 @@ class OpenIdConfigurationTest { assertEquals("", config.endSessionEndpoint) assertEquals("", config.pingEndIdpSessionEndpoint) assertEquals("", config.revocationEndpoint) + assertEquals("", config.deviceAuthorizationEndpoint) + } + + @Test + fun `OpenIdConfiguration should deserialize device_authorization_endpoint when present`() { + val json = """{"authorization_endpoint":"https://auth.example.com","device_authorization_endpoint":"https://example.com/device"}""" + val config = Json.decodeFromString(json) + assertEquals("https://example.com/device", config.deviceAuthorizationEndpoint) + } + + @Test + fun `OpenIdConfiguration should default deviceAuthorizationEndpoint to empty string when absent`() { + val json = """{"authorization_endpoint":"https://auth.example.com","token_endpoint":"https://token.example.com"}""" + val config = Json.decodeFromString(json) + assertEquals("", config.deviceAuthorizationEndpoint) } } \ No newline at end of file diff --git a/foundation/oidc/src/test/kotlin/com/pingidentity/oidc/Response.kt b/foundation/oidc/src/test/kotlin/com/pingidentity/oidc/Response.kt index 31c23fb8..0a4565d7 100644 --- a/foundation/oidc/src/test/kotlin/com/pingidentity/oidc/Response.kt +++ b/foundation/oidc/src/test/kotlin/com/pingidentity/oidc/Response.kt @@ -76,3 +76,16 @@ fun parResponse() = "}", ) +fun openIdConfigurationWithDeviceEndpointResponse() = + 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" + + " \"ping_end_idp_session_endpoint\" : \"https://auth.test-one-pingone.com/idp/signoff\",\n" + + " \"revocation_endpoint\" : \"https://auth.test-one-pingone.com/revoke\",\n" + + " \"device_authorization_endpoint\" : \"https://auth.test-one-pingone.com/device_authorization\"\n" + + "}", + ) + diff --git a/journey/src/main/kotlin/com/pingidentity/journey/Constants.kt b/journey/src/main/kotlin/com/pingidentity/journey/Constants.kt index e4016117..9d149d63 100644 --- a/journey/src/main/kotlin/com/pingidentity/journey/Constants.kt +++ b/journey/src/main/kotlin/com/pingidentity/journey/Constants.kt @@ -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. @@ -39,6 +39,10 @@ internal object Constants { const val TOKEN_ID = "tokenId" const val SUCCESS_URL = "successUrl" const val REALM_NAME = "realm" + const val ACCEPT = "Accept" + const val DECISION = "decision" + const val ALLOW = "allow" + const val CSRF = "csrf" // Constant key used to store and retrieve the OIDC client from the shared context const val OIDC_CLIENT = "com.pingidentity.journey.OIDC_CLIENT" diff --git a/journey/src/main/kotlin/com/pingidentity/journey/Journey.kt b/journey/src/main/kotlin/com/pingidentity/journey/Journey.kt index 2e9724d8..e5bd1b14 100644 --- a/journey/src/main/kotlin/com/pingidentity/journey/Journey.kt +++ b/journey/src/main/kotlin/com/pingidentity/journey/Journey.kt @@ -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. @@ -100,6 +100,9 @@ private fun option(context: SharedContext, block: Option.() -> Unit = {}) { context.apply { FORCE_AUTH to option.forceAuth NO_SESSION to option.noSession + option.parameters.forEach { (key, value) -> + this[key] = value + } } } @@ -142,5 +145,15 @@ fun Journey(block: JourneyConfig.() -> Unit = {}): Journey { * * @property forceAuth Whether to force authentication (default is false). * @property noSession Whether to return new session (default is false). + * @property parameters Additional key-value parameters to include in the authorization request. */ -data class Option(var forceAuth: Boolean = false, var noSession: Boolean = false) \ No newline at end of file +data class Option( + var forceAuth: Boolean = false, + var noSession: Boolean = false, + internal val parameters: MutableMap = mutableMapOf(), +) : MutableMap by parameters { + + infix fun String.to(value: Any) { + this@Option[this] = value + } +} \ No newline at end of file diff --git a/journey/src/main/kotlin/com/pingidentity/journey/module/Oidc.kt b/journey/src/main/kotlin/com/pingidentity/journey/module/Oidc.kt index 7ae3e57b..1ce3b146 100644 --- a/journey/src/main/kotlin/com/pingidentity/journey/module/Oidc.kt +++ b/journey/src/main/kotlin/com/pingidentity/journey/module/Oidc.kt @@ -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. @@ -7,19 +7,29 @@ package com.pingidentity.journey.module +import android.net.Uri +import com.pingidentity.exception.ApiException +import com.pingidentity.journey.Constants.ACCEPT +import com.pingidentity.journey.Constants.ALLOW +import com.pingidentity.journey.Constants.APPLICATION_JSON +import com.pingidentity.journey.Constants.CSRF +import com.pingidentity.journey.Constants.DECISION import com.pingidentity.journey.Constants.OIDC_CLIENT import com.pingidentity.journey.Journey import com.pingidentity.journey.SSOToken import com.pingidentity.journey.journey import com.pingidentity.journey.options import com.pingidentity.journey.prepareUser +import com.pingidentity.network.isSuccess +import com.pingidentity.oidc.Constants.USER_CODE import com.pingidentity.oidc.OidcClient import com.pingidentity.oidc.OidcClientConfig import com.pingidentity.oidc.OidcUser +import com.pingidentity.oidc.module.VERIFICATION_URI_COMPLETE +import com.pingidentity.oidc.module.deviceUserCode import com.pingidentity.orchestrate.EmptySession import com.pingidentity.orchestrate.Module import com.pingidentity.orchestrate.SuccessNode -import kotlin.collections.set // Defines the OIDC module for handling OpenID Connect (OIDC) flows @@ -48,14 +58,36 @@ val Oidc = // Defines the behavior when the module successfully completes success { success -> - SuccessNode( + + val successNode = SuccessNode( success.input, - prepareUser( - journey, - OidcUser(journey.oidcClient()), - success.session as SSOToken - ) + prepareUser(journey, OidcUser(journey.oidcClient()), success.session as SSOToken) ) + + // Check if the user code is present before making the request + val userCode = flowContext.deviceUserCode() ?: return@success successNode + // The session value may be empty due to NoSession or re-run existing Journey, + val existingSession = success.session.takeIf { it.value.isNotEmpty() } ?: journey.session() ?: success.session + + val response = httpClient.request { + url = flowContext.getValue(VERIFICATION_URI_COMPLETE).toString() + header(ACCEPT, APPLICATION_JSON) + header(journey.options.cookie, existingSession.value) + form { + put(USER_CODE, userCode) + put(DECISION, ALLOW) + // csrf must equal the SSO token value (AM CSRF protection) + put(CSRF, existingSession.value) + } + } + + if (!response.status.isSuccess()) { + logger.w("OidcDevice: device-user POST returned status ${response.status}, with body: ${response.body()}") + throw ApiException(response.status, response.body()) + } + + logger.i("Device authorization succeeded for user_code=$userCode") + successNode } // Defines the behavior for signing off the user diff --git a/journey/src/test/kotlin/com/pingidentity/journey/JourneyTest.Response.kt b/journey/src/test/kotlin/com/pingidentity/journey/JourneyTest.Response.kt index b48d945c..6430aff8 100644 --- a/journey/src/test/kotlin/com/pingidentity/journey/JourneyTest.Response.kt +++ b/journey/src/test/kotlin/com/pingidentity/journey/JourneyTest.Response.kt @@ -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" } """, ) diff --git a/journey/src/test/kotlin/com/pingidentity/journey/JourneyTest.kt b/journey/src/test/kotlin/com/pingidentity/journey/JourneyTest.kt index 8d0ff384..331a2039 100644 --- a/journey/src/test/kotlin/com/pingidentity/journey/JourneyTest.kt +++ b/journey/src/test/kotlin/com/pingidentity/journey/JourneyTest.kt @@ -10,6 +10,7 @@ package com.pingidentity.journey import android.content.Context import android.net.Uri import android.os.LocaleList +import androidx.core.net.toUri import com.pingidentity.journey.callback.NameCallback import com.pingidentity.journey.callback.PasswordCallback import com.pingidentity.journey.module.NodeTransform @@ -23,6 +24,7 @@ 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.orchestrate.ContinueNode import com.pingidentity.orchestrate.ErrorNode import com.pingidentity.orchestrate.FailureNode @@ -131,6 +133,10 @@ class JourneyTest { respond("", HttpStatusCode.Found, authorizeResponseHeaders) } + "/tenantId/applications/test/deviceFlow" -> { + respond("", HttpStatusCode.OK, headers) + } + else -> { return@MockEngine respond( content = @@ -944,6 +950,206 @@ class JourneyTest { assertContains(authorizeRequest.url.encodedQuery, "client_id=test") } + @Test + fun `Journey with device user code posts user code to verification URI on success`() = runTest { + val tokenStorage = MemoryStorage() + val sessionStorage = MemoryStorage() + val verificationUriComplete = + "https://auth.test-one-pingone.com/tenantId/applications/test/deviceFlow?user_code=WDJB-MJHT" + + val journey = Journey { + serverUrl = "http://localhost/am" + logger = Logger.CONSOLE + httpClient = KtorHttpClient(HttpClient(mockEngine) { + followRedirects = false + }) + module(Oidc) { + clientId = "test" + discoveryEndpoint = "http://localhost/.well-known/openid-configuration" + scopes = mutableSetOf("openid", "email", "address") + redirectUri = "http://localhost:8080" + storage = { tokenStorage } + } + module(Session) { + storage = { sessionStorage } + } + } + + var node = journey.start("myLogin") { + VERIFICATION_URI_COMPLETE to verificationUriComplete.toUri() + } + assertTrue(node is ContinueNode) + (node.callbacks[0] as? NameCallback)?.name = "My First Name" + (node.callbacks[1] as? PasswordCallback)?.password = "My Password" + + node = node.next() + assertTrue(node is SuccessNode) + assertEquals("Dummy Session Token", node.session.value) + + // Verify device flow POST request was made to the verification URI + val deviceFlowRequest = mockEngine.requestHistory.last() + assertEquals( + "https://auth.test-one-pingone.com/tenantId/applications/test/deviceFlow", + deviceFlowRequest.url.toString().substringBefore("?") + ) + val deviceFlowBody = deviceFlowRequest.body as FormDataContent + assertEquals("WDJB-MJHT", deviceFlowBody.formData["user_code"]) + assertEquals("allow", deviceFlowBody.formData["decision"]) + assertEquals("Dummy Session Token", deviceFlowBody.formData["csrf"]) + + // Verify the session cookie is set in the request header + assertEquals("Dummy Session Token", deviceFlowRequest.headers["iPlanetDirectoryPro"]) + } + + @Test + fun `Journey with device user code skips OIDC authorize and token exchange`() = runTest { + val tokenStorage = MemoryStorage() + val sessionStorage = MemoryStorage() + val verificationUriComplete = + "https://auth.test-one-pingone.com/tenantId/applications/test/deviceFlow?user_code=WDJB-MJHT" + + val journey = Journey { + serverUrl = "http://localhost/am" + logger = Logger.CONSOLE + httpClient = KtorHttpClient(HttpClient(mockEngine) { + followRedirects = false + }) + module(Oidc) { + clientId = "test" + discoveryEndpoint = "http://localhost/.well-known/openid-configuration" + scopes = mutableSetOf("openid", "email", "address") + redirectUri = "http://localhost:8080" + storage = { tokenStorage } + } + module(Session) { + storage = { sessionStorage } + } + } + + var node = journey.start("myLogin") { + VERIFICATION_URI_COMPLETE to verificationUriComplete.toUri() + } + assertTrue(node is ContinueNode) + (node.callbacks[0] as? NameCallback)?.name = "My First Name" + (node.callbacks[1] as? PasswordCallback)?.password = "My Password" + + node = node.next() + assertTrue(node is SuccessNode) + + // well-known, authenticate (x2), deviceFlow — no /authorize or /access_token + val paths = mockEngine.requestHistory.map { it.url.encodedPath } + assertTrue(paths.contains("/.well-known/openid-configuration")) + assertTrue(paths.contains("/am/json/realms/root/authenticate")) + assertTrue(paths.none { it == "/authorize" }) + assertTrue(paths.none { it == "/access_token" }) + } + + @Test + fun `Journey with device user code returns FailureNode when verification POST fails`() = runTest { + val verificationUriComplete = + "https://auth.test-one-pingone.com/tenantId/applications/test/deviceFlow?user_code=WDJB-MJHT" + + val failingEngine = MockEngine { request -> + when (request.url.encodedPath) { + "/.well-known/openid-configuration" -> + respond(openIdConfigurationResponse(), HttpStatusCode.OK, headers) + "/am/json/realms/root/authenticate" -> { + if (request.body is TextContent) { + val json = Json.parseToJsonElement((request.body as TextContent).text).jsonObject + if ((json["callbacks"]?.jsonArray?.size ?: 0) == 2) { + return@MockEngine respond(sessionResponse(), HttpStatusCode.OK, authenticateHeader) + } + } + return@MockEngine respond(authenticate(), HttpStatusCode.OK, authenticateHeader) + } + "/tenantId/applications/test/deviceFlow" -> + respond( + ByteReadChannel("""{"error":"access_denied"}"""), + HttpStatusCode.Forbidden, + headers + ) + else -> respond(ByteReadChannel(""), HttpStatusCode.InternalServerError) + } + } + + val journey = Journey { + serverUrl = "http://localhost/am" + logger = Logger.CONSOLE + httpClient = KtorHttpClient(HttpClient(failingEngine) { + followRedirects = false + }) + module(Oidc) { + clientId = "test" + discoveryEndpoint = "http://localhost/.well-known/openid-configuration" + scopes = mutableSetOf("openid", "email", "address") + redirectUri = "http://localhost:8080" + storage = { MemoryStorage() } + } + module(Session) { + storage = { MemoryStorage() } + } + } + + var node = journey.start("myLogin") { + VERIFICATION_URI_COMPLETE to verificationUriComplete.toUri() + } + assertTrue(node is ContinueNode) + (node.callbacks[0] as? NameCallback)?.name = "My First Name" + (node.callbacks[1] as? PasswordCallback)?.password = "My Password" + + node = node.next() + assertTrue(node is FailureNode) + assertTrue(node.cause is com.pingidentity.exception.ApiException) + val exception = node.cause as com.pingidentity.exception.ApiException + assertEquals(403, exception.status) + + failingEngine.close() + } + + @Test + fun `Journey without device user code proceeds with normal OIDC flow`() = runTest { + val tokenStorage = MemoryStorage() + val sessionStorage = MemoryStorage() + + val journey = Journey { + serverUrl = "http://localhost/am" + logger = Logger.CONSOLE + httpClient = KtorHttpClient(HttpClient(mockEngine) { + followRedirects = false + }) + module(Oidc) { + clientId = "test" + discoveryEndpoint = "http://localhost/.well-known/openid-configuration" + scopes = mutableSetOf("openid", "email", "address") + redirectUri = "http://localhost:8080" + storage = { tokenStorage } + } + module(Session) { + storage = { sessionStorage } + } + } + + var node = journey.start("myLogin") + assertTrue(node is ContinueNode) + (node.callbacks[0] as? NameCallback)?.name = "My First Name" + (node.callbacks[1] as? PasswordCallback)?.password = "My Password" + + node = node.next() + assertTrue(node is SuccessNode) + + // Token exchange is lazy — trigger it by fetching the user token + val user = journey.user() + assertNotNull(user) + user.token() + + // Normal flow includes /authorize and /access_token after token() is called + val paths = mockEngine.requestHistory.map { it.url.encodedPath } + assertTrue(paths.contains("/authorize")) + assertTrue(paths.contains("/access_token")) + // No device flow request + assertTrue(paths.none { it.contains("deviceFlow") }) + } + private fun parResponse(): String = """ {