OXYGENHUB
# 1c42a70fcba3 oxygenhub-android / app / src / main / java / io / oxygenhub / android / api / DeviceFlow.kt
4.8 KiB · last changed Land proposals from the phone (closes #17) 10 days ago source blame history raw
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
}