feat(android): OAuth browser sign-in with PKCE

Reuses the server's built-in public desktop OAuth client — no backend
changes needed. "Sign in with browser" opens /session/authorize with a
generated PKCE challenge; after consent the /callback/desktop page
deep-links cloudreve://mount?code&state back into the app, which
exchanges it at /session/oauth/token (verifier + state verified) and
fills the session profile via /session/oauth/userinfo.

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
pull/3589/head
Tomas Dvorak 2 weeks ago
parent cc09f73e87
commit e4d65ca553

@ -242,7 +242,7 @@ Lives in `android/` in this repo. Kotlin + Jetpack Compose, Material 3.
- **API**: `api/v4` REST + OAuth token (entities exist: `oauthclient`, `oauthgrant`) — same surface the desktop `cloudreve-api` crate documents; port its models as the spec
- **Core features**: browse/download/upload files, share links, full-text search (done — query bar + offset pagination + parent-path/snippet rows), camera-upload (done — periodic WorkManager MediaStore sync, Wi-Fi-only constraint, ID dedup, settings dialog), offline-favorite files (done — "Keep offline" downloads to filesDir/offline, DataStore registry, star dialog with open/refresh/remove), local sync folder via SAF/WorkManager
- **System integration** (the "native, complete" ask): share-sheet target (done — SEND/SEND_MULTIPLE → UploadWorker), DocumentsProvider (done — Files-app browse/open/thumb/rename/delete/search), quick-share tile, notifications on share/task events (done — periodic /workflow poll → terminal-state notifications)
- **Auth**: webview OAuth flow → token; later passkey if backend exposes
- **Auth**: OAuth flow (done — browser consent + PKCE + `cloudreve://mount` deep link); later passkey if backend exposes
- **WebDAV bridge**: `/dav` works as fallback file access until SDK matures
- Non-goals: iOS, tablet-first layouts (works, not optimized)

@ -44,6 +44,9 @@ CI runs `assembleDebug` on every PR.
- Task notifications: periodic `GET /workflow` poll posts a
notification when a task completes/fails/cancels — toggle in the
settings dialog, runtime POST_NOTIFICATIONS permission gated
- OAuth sign-in: "Sign in with browser" opens the server's consent
page with PKCE; the `/callback/desktop` page deep-links the code
back through `cloudreve://mount`, exchanged at `/session/oauth/token`
## Planned next

@ -36,6 +36,12 @@
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="cloudreve" android:host="mount" />
</intent-filter>
</activity>
<provider

@ -1,6 +1,7 @@
package org.cloudreve.android
import android.app.Application
import kotlinx.coroutines.flow.MutableStateFlow
import org.cloudreve.android.api.ApiClient
import org.cloudreve.android.data.CameraUploadSettings
import org.cloudreve.android.data.FavoritesStore
@ -10,6 +11,10 @@ import org.cloudreve.android.data.TaskNotifySettings
class CloudreveApp : Application() {
/** OAuth code+state arriving via the `cloudreve://mount` deep link. */
data class OAuthCallback(val code: String, val state: String)
val oauthCallback = MutableStateFlow<OAuthCallback?>(null)
lateinit var sessionManager: SessionManager
private set
lateinit var apiClient: ApiClient

@ -42,8 +42,20 @@ class MainActivity : ComponentActivity() {
handleShareIntent(intent)
}
// Share-sheet target: queue incoming files to the account root.
// Share-sheet target + OAuth deep link.
private fun handleShareIntent(intent: Intent?) {
if (intent?.action == Intent.ACTION_VIEW) {
val data = intent.data
if (data?.scheme == "cloudreve" && data.host == "mount") {
val code = data.getQueryParameter("code")
val state = data.getQueryParameter("state") ?: ""
if (!code.isNullOrEmpty()) {
(application as CloudreveApp).oauthCallback.value =
CloudreveApp.OAuthCallback(code, state)
}
}
return
}
val uris = when (intent?.action) {
Intent.ACTION_SEND -> listOfNotNull(
@Suppress("DEPRECATION")

@ -5,7 +5,10 @@ import okhttp3.ResponseBody
import retrofit2.Response
import retrofit2.http.Body
import retrofit2.http.DELETE
import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.HTTP
import retrofit2.http.POST
import retrofit2.http.PUT
@ -18,6 +21,22 @@ interface ApiService {
@POST("api/v4/session/token")
suspend fun login(@Body request: PasswordLoginRequest): Response<ApiResponse<LoginResponse>>
// OAuth token exchange returns raw OAuth JSON, not the ApiResponse envelope.
@FormUrlEncoded
@POST("api/v4/session/oauth/token")
suspend fun oauthToken(
@Field("client_id") clientId: String,
@Field("grant_type") grantType: String,
@Field("code") code: String,
@Field("redirect_uri") redirectUri: String,
@Field("code_verifier") codeVerifier: String,
): Response<OAuthTokenResponse>
@GET("api/v4/session/oauth/userinfo")
suspend fun oauthUserInfo(
@Header("Authorization") bearer: String,
): Response<UserInfoResponse>
@POST("api/v4/session/token/refresh")
suspend fun refreshToken(@Body request: RefreshTokenRequest): Response<ApiResponse<RefreshTokenResponse>>

@ -206,3 +206,19 @@ data class TaskItem(
data class TaskListResponse(
val tasks: List<TaskItem> = emptyList(),
)
@Serializable
data class OAuthTokenResponse(
@SerialName("access_token") val accessToken: String = "",
@SerialName("refresh_token") val refreshToken: String = "",
@SerialName("expires_in") val expiresIn: Long = 0,
@SerialName("token_type") val tokenType: String = "",
)
@Serializable
data class UserInfoResponse(
val sub: String = "",
val name: String = "",
@SerialName("preferred_username") val preferredUsername: String = "",
val email: String = "",
)

@ -25,6 +25,8 @@ class SessionManager(private val context: Context) {
val accessExpiresAt = longPreferencesKey("access_expires_at")
val userEmail = stringPreferencesKey("user_email")
val userNick = stringPreferencesKey("user_nick")
val pendingOauthVerifier = stringPreferencesKey("pending_oauth_verifier")
val pendingOauthState = stringPreferencesKey("pending_oauth_state")
}
val serverUrl: Flow<String> = context.sessionStore.data.map { it[Keys.serverUrl] ?: "" }
@ -46,6 +48,26 @@ class SessionManager(private val context: Context) {
context.sessionStore.edit { it[Keys.serverUrl] = url.trimEnd('/') }
}
suspend fun savePendingOAuth(verifier: String, state: String) {
context.sessionStore.edit {
it[Keys.pendingOauthVerifier] = verifier
it[Keys.pendingOauthState] = state
}
}
/** Returns the pending verifier+state pair once, then clears it. */
suspend fun takePendingOAuth(): Pair<String, String>? {
val prefs = context.sessionStore.data.first()
val verifier = prefs[Keys.pendingOauthVerifier]
val state = prefs[Keys.pendingOauthState]
if (verifier.isNullOrEmpty() || state.isNullOrEmpty()) return null
context.sessionStore.edit {
it.remove(Keys.pendingOauthVerifier)
it.remove(Keys.pendingOauthState)
}
return verifier to state
}
suspend fun saveSession(
accessToken: String,
refreshToken: String,

@ -27,11 +27,21 @@ import androidx.compose.ui.unit.dp
@Composable
fun LoginScreen(viewModel: LoginViewModel, onLoggedIn: () -> Unit) {
val state by viewModel.state.collectAsState()
val context = androidx.compose.ui.platform.LocalContext.current
val app = context.applicationContext as org.cloudreve.android.CloudreveApp
val oauth by app.oauthCallback.collectAsState()
LaunchedEffect(state.loggedIn) {
if (state.loggedIn) onLoggedIn()
}
LaunchedEffect(oauth) {
oauth?.let {
app.oauthCallback.value = null
viewModel.completeOAuth(it.code, it.state)
}
}
Column(
modifier = Modifier
.fillMaxSize()
@ -94,5 +104,22 @@ fun LoginScreen(viewModel: LoginViewModel, onLoggedIn: () -> Unit) {
Text("Sign in")
}
}
Spacer(Modifier.height(12.dp))
androidx.compose.material3.OutlinedButton(
onClick = {
viewModel.startOAuth()?.let { url ->
context.startActivity(
android.content.Intent(
android.content.Intent.ACTION_VIEW,
android.net.Uri.parse(url),
)
)
}
},
enabled = !state.loading,
modifier = Modifier.fillMaxWidth(),
) {
Text("Sign in with browser")
}
}
}

@ -8,6 +8,9 @@ import kotlinx.coroutines.launch
import org.cloudreve.android.api.ApiClient
import org.cloudreve.android.api.PasswordLoginRequest
import org.cloudreve.android.data.SessionManager
import java.net.URLEncoder
import java.security.MessageDigest
import java.security.SecureRandom
data class LoginUiState(
val serverUrl: String = "",
@ -59,4 +62,87 @@ class LoginViewModel(
}
}
}
/**
* Builds the server-side authorize URL for browser sign-in and stashes
* the PKCE verifier + state. The consent page bounces the code to the
* app through the `cloudreve://mount` deep link. Returns null when the
* server URL is missing.
*/
fun startOAuth(): String? {
val s = _state.value
if (s.serverUrl.isBlank()) {
_state.value = s.copy(error = "Enter the server URL first")
return null
}
val verifier = randomBase64Url(48)
val challenge = base64Url(MessageDigest.getInstance("SHA-256").digest(verifier.toByteArray()))
val state = randomBase64Url(24)
viewModelScope.launch {
session.saveServerUrl(s.serverUrl)
session.savePendingOAuth(verifier, state)
}
val enc: (String) -> String = { URLEncoder.encode(it, "UTF-8") }
return "${s.serverUrl.trimEnd('/')}/session/authorize" +
"?response_type=code&client_id=${enc(OAUTH_CLIENT_ID)}" +
"&redirect_uri=${enc(OAUTH_REDIRECT)}&state=${enc(state)}" +
"&scope=${enc(OAUTH_SCOPES)}&code_challenge=${enc(challenge)}" +
"&code_challenge_method=S256"
}
/** Exchanges the deep-link code for tokens. Called by the UI on `cloudreve://mount`. */
fun completeOAuth(code: String, state: String) {
_state.value = _state.value.copy(loading = true, error = null)
viewModelScope.launch {
try {
val pending = session.takePendingOAuth()
?: throw Exception("No pending sign-in — start again")
if (pending.second != state) {
throw Exception("OAuth state mismatch")
}
val resp = api.service().oauthToken(
clientId = OAUTH_CLIENT_ID,
grantType = "authorization_code",
code = code,
redirectUri = OAUTH_REDIRECT,
codeVerifier = pending.first,
)
val token = resp.body()
if (!resp.isSuccessful || token == null || token.accessToken.isEmpty()) {
throw Exception("Token exchange failed (HTTP ${resp.code()})")
}
// The token response carries no user; fetch profile from userinfo.
val info = runCatching {
api.service().oauthUserInfo("Bearer ${token.accessToken}").body()
}.getOrNull()
session.saveSession(
accessToken = token.accessToken,
refreshToken = token.refreshToken,
accessExpiresAt = if (token.expiresIn > 0) {
System.currentTimeMillis() + token.expiresIn * 1000
} else 0L,
email = info?.email ?: "",
nick = info?.name?.ifEmpty { info.preferredUsername } ?: "",
)
_state.value = _state.value.copy(loading = false, loggedIn = true)
} catch (e: Exception) {
_state.value = _state.value.copy(loading = false, error = e.message ?: "Sign-in failed")
}
}
}
private fun randomBase64Url(bytes: Int): String =
base64Url(ByteArray(bytes).also { SecureRandom().nextBytes(it) })
private fun base64Url(data: ByteArray): String =
android.util.Base64.encodeToString(data, android.util.Base64.URL_SAFE or android.util.Base64.NO_PADDING or android.util.Base64.NO_WRAP)
.trim()
companion object {
// Built-in public OAuth client (seeded by the server migration).
private const val OAUTH_CLIENT_ID = "393a1839-f52e-498e-9972-e77cc2241eee"
private const val OAUTH_REDIRECT = "/callback/desktop"
private const val OAUTH_SCOPES =
"profile email openid offline_access UserInfo.Write UserSecurityInfo.Write Workflow.Write Files.Write Shares.Write"
}
}

Loading…
Cancel
Save