diff --git a/ROADMAP.md b/ROADMAP.md index 616f7310..61d27b09 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -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) diff --git a/android/README.md b/android/README.md index 241374fb..84579152 100644 --- a/android/README.md +++ b/android/README.md @@ -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 diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 23b3e9ac..2e26dfde 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -36,6 +36,12 @@ + + + + + + (null) + lateinit var sessionManager: SessionManager private set lateinit var apiClient: ApiClient diff --git a/android/app/src/main/java/org/cloudreve/android/MainActivity.kt b/android/app/src/main/java/org/cloudreve/android/MainActivity.kt index 48b54964..e1e156b3 100644 --- a/android/app/src/main/java/org/cloudreve/android/MainActivity.kt +++ b/android/app/src/main/java/org/cloudreve/android/MainActivity.kt @@ -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") diff --git a/android/app/src/main/java/org/cloudreve/android/api/ApiService.kt b/android/app/src/main/java/org/cloudreve/android/api/ApiService.kt index 71ffb881..9201928f 100644 --- a/android/app/src/main/java/org/cloudreve/android/api/ApiService.kt +++ b/android/app/src/main/java/org/cloudreve/android/api/ApiService.kt @@ -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> + // 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 + + @GET("api/v4/session/oauth/userinfo") + suspend fun oauthUserInfo( + @Header("Authorization") bearer: String, + ): Response + @POST("api/v4/session/token/refresh") suspend fun refreshToken(@Body request: RefreshTokenRequest): Response> diff --git a/android/app/src/main/java/org/cloudreve/android/api/Dto.kt b/android/app/src/main/java/org/cloudreve/android/api/Dto.kt index 285e39be..07b64642 100644 --- a/android/app/src/main/java/org/cloudreve/android/api/Dto.kt +++ b/android/app/src/main/java/org/cloudreve/android/api/Dto.kt @@ -206,3 +206,19 @@ data class TaskItem( data class TaskListResponse( val tasks: List = 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 = "", +) diff --git a/android/app/src/main/java/org/cloudreve/android/data/SessionManager.kt b/android/app/src/main/java/org/cloudreve/android/data/SessionManager.kt index 7e44d379..fec68485 100644 --- a/android/app/src/main/java/org/cloudreve/android/data/SessionManager.kt +++ b/android/app/src/main/java/org/cloudreve/android/data/SessionManager.kt @@ -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 = 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? { + 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, diff --git a/android/app/src/main/java/org/cloudreve/android/ui/login/LoginScreen.kt b/android/app/src/main/java/org/cloudreve/android/ui/login/LoginScreen.kt index dbd4182a..74011b4d 100644 --- a/android/app/src/main/java/org/cloudreve/android/ui/login/LoginScreen.kt +++ b/android/app/src/main/java/org/cloudreve/android/ui/login/LoginScreen.kt @@ -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") + } } } diff --git a/android/app/src/main/java/org/cloudreve/android/ui/login/LoginViewModel.kt b/android/app/src/main/java/org/cloudreve/android/ui/login/LoginViewModel.kt index b966e838..8de5ba4b 100644 --- a/android/app/src/main/java/org/cloudreve/android/ui/login/LoginViewModel.kt +++ b/android/app/src/main/java/org/cloudreve/android/ui/login/LoginViewModel.kt @@ -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" + } }