Coverage / app/src/main/java/io/oxygenhub/android/api/DeviceFlow.kt 94.0%
47 of 50 lines run, 14 of 20 functions, 15 of 19 branches · run #1632 on main · run missed
| 1 | package io.oxygenhub.android.api | |
| 2 | ||
| 3 | import kotlinx.coroutines.delay | |
| 4 | import kotlinx.serialization.SerialName | |
| 5 | import kotlinx.serialization.Serializable | |
| 6 | import okhttp3.HttpUrl.Companion.toHttpUrl | |
| 7 | import okhttp3.OkHttpClient | |
| 8 | import okhttp3.Request | |
| 9 | import okhttp3.RequestBody.Companion.toRequestBody | |
| 10 | import java.io.IOException | |
| 11 | ||
| 12 | /** The scopes the app asks for. The user may untick any of them on the web. */ | |
| 13 | val AppScopes: List<String> = listOf("repo:read", "repo:write", "user:read") | |
| 14 | ||
| 15 | /** Lands proposals (and more as the app grows); asked for since #17. */ | |
| 16 | const val WriteScope = "repo:write" | |
| 17 | ||
| 18 | /** Why a device sign-in ended without a token. */ | |
| 19 | sealed class DeviceFlowException(message: String) : Exception(message) { | |
| 20 | class Denied : DeviceFlowException("The sign-in was denied on the web.") | |
| 21 | class Expired : DeviceFlowException("The code expired before it was approved. Start again for a new one.") | |
| 22 | class Refused(val status: Int, val detail: String) : DeviceFlowException(detail) | |
| 23 | } | |
| 24 | ||
| 25 | /** | |
| 26 | * Device sign-in (oxygenhub#67, RFC 8628's shape), as `oh auth login` does | |
| 27 | * it: ask the hub for a code, show it, the user approves it on the hub's | |
| 28 | * `/device` page (signed in there with their password and, if they use one, | |
| 29 | * their second factor), and the app's next poll gets a token. The app never | |
| 30 | * sees a password or a second-factor code. | |
| 31 | */ | |
| 32 | interface DeviceAuth { | |
| 33 | /** `POST /device/code`: a code to show, and one to poll with. */ | |
| 34 | suspend fun requestCode(client: String, scopes: List<String> = AppScopes): DeviceCode | |
| 35 | ||
| 36 | /** | |
| 37 | * Polls `POST /device/token` every `interval` seconds (five more after a | |
| 38 | * `slow_down`) until the user answers or the code expires. | |
| 39 | */ | |
| 40 | suspend fun awaitToken(code: DeviceCode): TokenGrant | |
| 41 | ||
| 42 | /** The account a new token belongs to, checking that the token works. */ | |
| 43 | suspend fun whoAmI(token: String): User | |
| 44 | } | |
| 45 | ||
| 46 | class HubDeviceAuth( | |
| 47 | private val hub: String, | |
| 48 | private val http: OkHttpClient, | |
| 49 | /** How the poll waits; tests pass a recorder. */ | |
| 50 | private val sleep: suspend (Long) -> Unit = { delay(it) }, | |
| 51 | ) : DeviceAuth { | |
| 52 | private val base = "${hub.trimEnd('/')}/api/v1/".toHttpUrl() | |
| 53 | ||
| 54 | @Serializable | |
| 55 | private data class CodeRequest(val client: String, val scopes: List<String>) | |
| 56 | ||
| 57 | @Serializable | |
| 58 | private data class TokenRequest(@SerialName("device_code") val deviceCode: String) | |
| 59 | ||
| 60 | override suspend fun requestCode(client: String, scopes: List<String>): DeviceCode { | |
| 61 | val (status, body) = post("device/code", HubJson.encodeToString(CodeRequest.serializer(), CodeRequest(client.take(80), scopes))) | |
| 62 | if (status != 200) throw DeviceFlowException.Refused(status, "$hub cannot sign in a device: ${errorDetail(body)}") | |
| 63 | return decode(body, DeviceCode.serializer(), "device/code") | |
| 64 | } | |
| 65 | ||
| 66 | override suspend fun awaitToken(code: DeviceCode): TokenGrant { | |
| 67 | var interval = code.interval.coerceAtLeast(1) | |
| 68 | val life = code.expiresIn.coerceAtLeast(1) * 1000 | |
| 69 | var waited = 0L | |
| 70 | val request = HubJson.encodeToString(TokenRequest.serializer(), TokenRequest(code.deviceCode)) | |
| 71 | while (true) { | |
| 72 | sleep(interval * 1000) | |
| 73 | waited += interval * 1000 | |
| 74 | if (waited > life) throw DeviceFlowException.Expired() | |
| 75 | val (status, body) = post("device/token", request) | |
| 76 | if (status == 200) return decode(body, TokenGrant.serializer(), "device/token") | |
| 77 | val error = runCatching { HubJson.decodeFromString(OAuthError.serializer(), body) }.getOrNull() | |
| 78 | when (error?.error) { | |
| 79 | "authorization_pending" -> {} | |
| 80 | "slow_down" -> interval += 5 | |
| 81 | "access_denied" -> throw DeviceFlowException.Denied() | |
| 82 | "expired_token" -> throw DeviceFlowException.Expired() | |
| 83 | else -> throw DeviceFlowException.Refused(status, "Signing in failed: HTTP $status: ${errorDetail(body)}") | |
| 84 | } | |
| 85 | } | |
| 86 | } | |
| 87 | ||
| 88 | override suspend fun whoAmI(token: String): User = HubClient(hub, token, http).user() | |
| 89 | ||
| 90 | /** POSTs JSON, returning the status and body whatever the status: the poll's waits are errors by design. */ | |
| 91 | private suspend fun post(path: String, json: String): Pair<Int, String> { | |
| 92 | val request = Request.Builder() | |
| 93 | .url(base.resolve(path)!!) | |
| 94 | .header("Accept", "application/json") | |
| 95 | .post(json.toRequestBody(JsonMedia)) | |
| 96 | .build() | |
| 97 | return try { | |
| 98 | http.newCall(request).await().use { r -> r.code to r.body?.string().orEmpty() } | |
| 99 | } catch (e: IOException) { | |
| 100 | throw HubException.Network(e) | |
| 101 | } | |
| 102 | } | |
| 103 | ||
| 104 | private fun <T> decode(body: String, serializer: kotlinx.serialization.KSerializer<T>, what: String): T = try { | |
| 105 | HubJson.decodeFromString(serializer, body) | |
| 106 | } catch (e: IllegalArgumentException) { | |
| 107 | throw HubException.Unreadable(what, e) | |
| 108 | } | |
| 109 | } |