Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/android-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ jobs:
timeout-minutes: 45
env:
GITHUB_CLIENT_ID: ${{ vars.PUBLIC_GITHUB_OAUTH_CLIENT_ID || 'Ov23lim8WhLjeUMqvuMj' }}
GITHUB_ROCK_BACKEND_URL: ${{ vars.GITHUB_ROCK_BACKEND_URL || '' }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-java@v4
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ jobs:
timeout-minutes: 45
env:
GITHUB_CLIENT_ID: ${{ vars.PUBLIC_GITHUB_OAUTH_CLIENT_ID || 'Ov23lim8WhLjeUMqvuMj' }}
GITHUB_ROCK_BACKEND_URL: ${{ vars.GITHUB_ROCK_BACKEND_URL || '' }}
EXPECTED_RELEASE_CERT_SHA256: ${{ vars.EXPECTED_RELEASE_CERT_SHA256 }}
INPUT_VERSION: ${{ inputs.version }}
INPUT_PRERELEASE: ${{ inputs.prerelease }}
Expand Down
10 changes: 10 additions & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,11 @@ val githubClientId = sequenceOf(
System.getenv("GITHUB_CLIENT_ID"),
bundledGitHubClientId
).firstOrNull { !it.isNullOrBlank() }.orEmpty()
val backendBaseUrl = sequenceOf(
localProperties.getProperty("GITHUB_ROCK_BACKEND_URL"),
System.getenv("GITHUB_ROCK_BACKEND_URL"),
providers.gradleProperty("GITHUB_ROCK_BACKEND_URL").orNull
).firstOrNull { !it.isNullOrBlank() }.orEmpty()
val configuredVersionName = providers.gradleProperty("GITHUB_ROCK_VERSION_NAME").orNull
?.trim()?.takeIf(String::isNotBlank) ?: "0.1.0"
val configuredVersionCode = providers.gradleProperty("GITHUB_ROCK_VERSION_CODE").orNull
Expand All @@ -46,6 +51,11 @@ android {
"GITHUB_CLIENT_ID",
quotedBuildConfig(githubClientId)
)
buildConfigField(
"String",
"BACKEND_BASE_URL",
quotedBuildConfig(backendBaseUrl)
)
buildConfigField("String", "GITHUB_API_VERSION", "\"2022-11-28\"")
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ class AppInformationScreenTest {
),
onBack = {},
onOpenCapabilities = {},
onOpenBackend = {},
onOpenSystemSettings = {}
)
}
Expand All @@ -44,6 +45,7 @@ class AppInformationScreenTest {
compose.onNodeWithText("SDK information").performScrollTo().assertIsDisplayed()
compose.onNodeWithText("Target Android").performScrollTo().assertIsDisplayed()
compose.onNodeWithText("API 36", useUnmergedTree = true).performScrollTo().assertIsDisplayed()
compose.onNodeWithText("GitHub Rock Backend connection").performScrollTo().assertIsDisplayed()
compose.onNodeWithText("Android capabilities & permissions").performScrollTo().assertIsDisplayed()
compose.onNodeWithText("Open Android app settings").performScrollTo().assertIsDisplayed()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package com.sayanthrock.githubrock.core.network

import com.sayanthrock.githubrock.core.model.DeviceCodeResponse
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST

@Serializable
data class BackendHealthResponse(
val status: String,
val version: String,
val postgres: String,
val redis: String,
val meilisearch: String,
val timestamp: String,
)

@Serializable
data class BackendPublicConfigResponse(
val apiVersion: String = "v1",
val minSupportedAppVersion: String,
val latestAppVersion: String,
val maintenanceMode: Boolean,
val features: Map<String, Boolean> = emptyMap(),
)

@Serializable
data class BackendDevicePollRequest(
@SerialName("device_code") val deviceCode: String,
)

@Serializable
data class BackendTokenRefreshRequest(
@SerialName("refresh_token") val refreshToken: String,
)

@Serializable
data class BackendDeviceTokenResponse(
val state: String,
@SerialName("access_token") val accessToken: String? = null,
@SerialName("token_type") val tokenType: String? = null,
val scope: String? = null,
@SerialName("expires_in") val expiresIn: Long? = null,
@SerialName("refresh_token") val refreshToken: String? = null,
@SerialName("refresh_token_expires_in") val refreshTokenExpiresIn: Long? = null,
val message: String? = null,
val interval: Int? = null,
)

interface GitHubRockBackendApi {
@GET("v1/health")
suspend fun health(): BackendHealthResponse

@GET("v1/config")
suspend fun config(): BackendPublicConfigResponse

@POST("v1/auth/device/start")
suspend fun startDeviceFlow(): DeviceCodeResponse

@POST("v1/auth/device/poll")
suspend fun pollDeviceFlow(
@Body request: BackendDevicePollRequest,
): BackendDeviceTokenResponse

@POST("v1/auth/device/refresh")
suspend fun refreshToken(
@Body request: BackendTokenRefreshRequest,
): BackendDeviceTokenResponse
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,38 +6,68 @@ import com.sayanthrock.githubrock.core.model.DeviceTokenResponse
import com.sayanthrock.githubrock.core.network.GitHubAuthApi
import com.sayanthrock.githubrock.core.security.StoredTokens
import com.sayanthrock.githubrock.core.security.TokenStore
import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import com.sayanthrock.githubrock.data.backend.BackendGateway
import java.time.Instant
import javax.inject.Inject
import javax.inject.Singleton
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.delay
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock

class DeviceFlowException(message: String) : IllegalStateException(message)

internal const val GITHUB_OAUTH_SCOPES =
"repo workflow read:user user:email read:org notifications user:follow"

private enum class DeviceFlowTransport { Backend, DirectGitHub }

@Singleton
class DeviceFlowAuthRepository @Inject constructor(
private val api: GitHubAuthApi,
private val tokenStore: TokenStore
private val tokenStore: TokenStore,
private val backendGateway: BackendGateway,
) {
val isConfigured: Boolean get() = BuildConfig.GITHUB_CLIENT_ID.isNotBlank()
val isConfigured: Boolean
get() = backendGateway.isConfigured || BuildConfig.GITHUB_CLIENT_ID.isNotBlank()
val hasSession: Boolean get() = tokenStore.read() != null

private val pollMutex = Mutex()
private var lastTokenRequestAtMillis = 0L
private var requiredIntervalSeconds = MINIMUM_POLL_INTERVAL_SECONDS
@Volatile private var activeTransport = DeviceFlowTransport.DirectGitHub
Comment thread
SayanthRock marked this conversation as resolved.

suspend fun begin(): DeviceCodeResponse {
check(isConfigured) { "Add GITHUB_CLIENT_ID to local.properties before using GitHub login." }
return api.requestDeviceCode(
clientId = BuildConfig.GITHUB_CLIENT_ID,
scope = GITHUB_OAUTH_SCOPES
).also { device ->
check(isConfigured) {
"Connect GitHub Rock Backend or add GITHUB_CLIENT_ID before using GitHub login."
}

val device = if (backendGateway.isConfigured) {
try {
backendGateway.startDeviceFlow().also {
activeTransport = DeviceFlowTransport.Backend
}
} catch (cancelled: CancellationException) {
throw cancelled
} catch (backendFailure: Exception) {
if (BuildConfig.GITHUB_CLIENT_ID.isBlank()) {
throw DeviceFlowException(
"GitHub Rock Backend could not start login: ${backendFailure.message ?: "connection failed"}"
)
}
requestDirectDeviceCode().also {
activeTransport = DeviceFlowTransport.DirectGitHub
}
}
} else {
requestDirectDeviceCode().also {
activeTransport = DeviceFlowTransport.DirectGitHub
}
}

return device.also {
pollMutex.withLock {
requiredIntervalSeconds = device.interval.coerceAtLeast(MINIMUM_POLL_INTERVAL_SECONDS)
requiredIntervalSeconds = it.interval.coerceAtLeast(MINIMUM_POLL_INTERVAL_SECONDS)
lastTokenRequestAtMillis = elapsedRealtimeMillis()
}
}
Expand All @@ -59,9 +89,11 @@ class DeviceFlowAuthRepository @Inject constructor(
"Device Flow is disabled for this GitHub OAuth App. Enable it in the OAuth App settings."
)
"incorrect_client_credentials" -> throw DeviceFlowException(
"This build has an invalid GitHub OAuth client ID."
"This build or backend has an invalid GitHub OAuth client configuration."
)
else -> throw DeviceFlowException(
response.errorDescription ?: "GitHub authentication failed."
)
else -> throw DeviceFlowException(response.errorDescription ?: "GitHub authentication failed.")
}
}
throw DeviceFlowException("The device code expired. Start login again.")
Expand All @@ -74,34 +106,66 @@ class DeviceFlowAuthRepository @Inject constructor(
if (expiresAt > now + SESSION_EXPIRY_SKEW_SECONDS) return true
if (!isRefreshTokenUsable(stored.refreshToken, stored.refreshExpiresAtEpochSeconds, now)) return false

val response = api.refreshToken(
clientId = BuildConfig.GITHUB_CLIENT_ID,
refreshToken = requireNotNull(stored.refreshToken)
)
val refreshToken = requireNotNull(stored.refreshToken)
val response = refreshThroughBackendOrGitHub(refreshToken)
val token = response.accessToken ?: return false
tokenStore.save(response.toStoredTokens(token))
return true
}

fun logout() = tokenStore.clear()

private suspend fun requestDirectDeviceCode(): DeviceCodeResponse {
check(BuildConfig.GITHUB_CLIENT_ID.isNotBlank()) {
"Add GITHUB_CLIENT_ID to local.properties before using direct GitHub login."
}
return api.requestDeviceCode(
clientId = BuildConfig.GITHUB_CLIENT_ID,
scope = GITHUB_OAUTH_SCOPES,
)
}

private suspend fun refreshThroughBackendOrGitHub(refreshToken: String): DeviceTokenResponse {
if (backendGateway.isConfigured) {
try {
val backendResponse = backendGateway.refreshToken(refreshToken)
if (!backendResponse.accessToken.isNullOrBlank()) return backendResponse
} catch (cancelled: CancellationException) {
throw cancelled
} catch (_: Exception) {
// A configured backend is preferred, but direct GitHub remains the availability fallback.
}
}
if (BuildConfig.GITHUB_CLIENT_ID.isBlank()) return DeviceTokenResponse(error = "refresh_unavailable")
return api.refreshToken(
clientId = BuildConfig.GITHUB_CLIENT_ID,
refreshToken = refreshToken,
)
}

private suspend fun requestTokenAtAllowedInterval(device: DeviceCodeResponse): DeviceTokenResponse =
pollMutex.withLock {
val now = elapsedRealtimeMillis()
val remainingDelay = remainingPollDelayMillis(
lastRequestAtMillis = lastTokenRequestAtMillis,
nowMillis = now,
intervalSeconds = requiredIntervalSeconds
intervalSeconds = requiredIntervalSeconds,
)
if (remainingDelay > 0L) delay(remainingDelay)
lastTokenRequestAtMillis = elapsedRealtimeMillis()
api.requestToken(BuildConfig.GITHUB_CLIENT_ID, device.deviceCode).also { response ->
requiredIntervalSeconds = nextPollIntervalSeconds(
currentIntervalSeconds = requiredIntervalSeconds,
error = response.error,
slowDownIncrementSeconds = SLOW_DOWN_INCREMENT_SECONDS
val response = when (activeTransport) {
DeviceFlowTransport.Backend -> backendGateway.pollDeviceFlow(device.deviceCode)
DeviceFlowTransport.DirectGitHub -> api.requestToken(
BuildConfig.GITHUB_CLIENT_ID,
device.deviceCode,
)
}
Comment thread
SayanthRock marked this conversation as resolved.
requiredIntervalSeconds = nextPollIntervalSeconds(
currentIntervalSeconds = requiredIntervalSeconds,
error = response.error,
slowDownIncrementSeconds = SLOW_DOWN_INCREMENT_SECONDS,
)
response
}

private fun DeviceTokenResponse.toStoredTokens(token: String): StoredTokens {
Expand All @@ -110,7 +174,7 @@ class DeviceFlowAuthRepository @Inject constructor(
accessToken = token,
refreshToken = refreshToken,
accessExpiresAtEpochSeconds = expiresIn?.let { now + it },
refreshExpiresAtEpochSeconds = refreshTokenExpiresIn?.let { now + it }
refreshExpiresAtEpochSeconds = refreshTokenExpiresIn?.let { now + it },
)
}

Expand All @@ -124,7 +188,7 @@ class DeviceFlowAuthRepository @Inject constructor(
internal fun remainingPollDelayMillis(
lastRequestAtMillis: Long,
nowMillis: Long,
intervalSeconds: Int
intervalSeconds: Int,
): Long {
val intervalMillis = intervalSeconds.coerceAtLeast(0) * 1_000L
if (lastRequestAtMillis <= 0L) return intervalMillis
Expand All @@ -136,7 +200,7 @@ private fun elapsedRealtimeMillis(): Long = System.nanoTime() / 1_000_000L
internal fun nextPollIntervalSeconds(
currentIntervalSeconds: Int,
error: String?,
slowDownIncrementSeconds: Int = 5
slowDownIncrementSeconds: Int = 5,
): Int = if (error == "slow_down") {
currentIntervalSeconds + slowDownIncrementSeconds
} else {
Expand All @@ -146,6 +210,6 @@ internal fun nextPollIntervalSeconds(
internal fun isRefreshTokenUsable(
refreshToken: String?,
refreshExpiresAtEpochSeconds: Long?,
nowEpochSeconds: Long
nowEpochSeconds: Long,
): Boolean = !refreshToken.isNullOrBlank() &&
(refreshExpiresAtEpochSeconds == null || refreshExpiresAtEpochSeconds > nowEpochSeconds + 60L)
Loading
Loading