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