-
Notifications
You must be signed in to change notification settings - Fork 3
SDKS-4784 Implement OAuth 2.0 Device Authorization Grant (RFC 8628) support #199
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -10,3 +10,4 @@ local.properties | |
| xcuserdata | ||
| .kotlin | ||
| .polaris/ | ||
| .claude/settings.local.json | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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<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()) | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 = | ||
| """ | ||
| { | ||
|
|
||
| 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() | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use the correct query parameter key in assertion.
The test checks
userCode, but this flow passesuser_code. This can fail for the right implementation or lock in the wrong contract.Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents