@ -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 "
}
}