Merge pull request #220 from Dvorinka/feat/android-oauth

feat(android): OAuth browser sign-in with PKCE
pull/3589/head
Tomáš Dvořák 2 weeks ago committed by GitHub
commit 518cf49eed
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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 - **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 - **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) - **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 - **WebDAV bridge**: `/dav` works as fallback file access until SDK matures
- Non-goals: iOS, tablet-first layouts (works, not optimized) - 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 - Task notifications: periodic `GET /workflow` poll posts a
notification when a task completes/fails/cancels — toggle in the notification when a task completes/fails/cancels — toggle in the
settings dialog, runtime POST_NOTIFICATIONS permission gated 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 ## Planned next

@ -36,6 +36,12 @@
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" /> <data android:mimeType="*/*" />
</intent-filter> </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> </activity>
<provider <provider

@ -1,6 +1,7 @@
package org.cloudreve.android package org.cloudreve.android
import android.app.Application import android.app.Application
import kotlinx.coroutines.flow.MutableStateFlow
import org.cloudreve.android.api.ApiClient import org.cloudreve.android.api.ApiClient
import org.cloudreve.android.data.CameraUploadSettings import org.cloudreve.android.data.CameraUploadSettings
import org.cloudreve.android.data.FavoritesStore import org.cloudreve.android.data.FavoritesStore
@ -10,6 +11,10 @@ import org.cloudreve.android.data.TaskNotifySettings
class CloudreveApp : Application() { 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 lateinit var sessionManager: SessionManager
private set private set
lateinit var apiClient: ApiClient lateinit var apiClient: ApiClient

@ -42,8 +42,20 @@ class MainActivity : ComponentActivity() {
handleShareIntent(intent) handleShareIntent(intent)
} }
// Share-sheet target: queue incoming files to the account root. // Share-sheet target + OAuth deep link.
private fun handleShareIntent(intent: Intent?) { 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) { val uris = when (intent?.action) {
Intent.ACTION_SEND -> listOfNotNull( Intent.ACTION_SEND -> listOfNotNull(
@Suppress("DEPRECATION") @Suppress("DEPRECATION")

@ -5,7 +5,10 @@ import okhttp3.ResponseBody
import retrofit2.Response import retrofit2.Response
import retrofit2.http.Body import retrofit2.http.Body
import retrofit2.http.DELETE import retrofit2.http.DELETE
import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Header
import retrofit2.http.HTTP import retrofit2.http.HTTP
import retrofit2.http.POST import retrofit2.http.POST
import retrofit2.http.PUT import retrofit2.http.PUT
@ -18,6 +21,22 @@ interface ApiService {
@POST("api/v4/session/token") @POST("api/v4/session/token")
suspend fun login(@Body request: PasswordLoginRequest): Response<ApiResponse<LoginResponse>> 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") @POST("api/v4/session/token/refresh")
suspend fun refreshToken(@Body request: RefreshTokenRequest): Response<ApiResponse<RefreshTokenResponse>> suspend fun refreshToken(@Body request: RefreshTokenRequest): Response<ApiResponse<RefreshTokenResponse>>

@ -206,3 +206,19 @@ data class TaskItem(
data class TaskListResponse( data class TaskListResponse(
val tasks: List<TaskItem> = emptyList(), 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 accessExpiresAt = longPreferencesKey("access_expires_at")
val userEmail = stringPreferencesKey("user_email") val userEmail = stringPreferencesKey("user_email")
val userNick = stringPreferencesKey("user_nick") 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] ?: "" } 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('/') } 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( suspend fun saveSession(
accessToken: String, accessToken: String,
refreshToken: String, refreshToken: String,

@ -27,11 +27,21 @@ import androidx.compose.ui.unit.dp
@Composable @Composable
fun LoginScreen(viewModel: LoginViewModel, onLoggedIn: () -> Unit) { fun LoginScreen(viewModel: LoginViewModel, onLoggedIn: () -> Unit) {
val state by viewModel.state.collectAsState() 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) { LaunchedEffect(state.loggedIn) {
if (state.loggedIn) onLoggedIn() if (state.loggedIn) onLoggedIn()
} }
LaunchedEffect(oauth) {
oauth?.let {
app.oauthCallback.value = null
viewModel.completeOAuth(it.code, it.state)
}
}
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
@ -94,5 +104,22 @@ fun LoginScreen(viewModel: LoginViewModel, onLoggedIn: () -> Unit) {
Text("Sign in") 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.ApiClient
import org.cloudreve.android.api.PasswordLoginRequest import org.cloudreve.android.api.PasswordLoginRequest
import org.cloudreve.android.data.SessionManager import org.cloudreve.android.data.SessionManager
import java.net.URLEncoder
import java.security.MessageDigest
import java.security.SecureRandom
data class LoginUiState( data class LoginUiState(
val serverUrl: String = "", 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