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