feat: connexion ROPC — formulaire natif sans redirection Keycloak

Remplace le flux PKCE/Custom Tab par un formulaire username/password
natif qui appelle directement le token endpoint Keycloak (grant_type=password).
Le token et le refresh token sont stockés dans EncryptedSharedPreferences.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-06 09:30:44 +02:00
parent d099fc7da7
commit ee67139b04
6 changed files with 162 additions and 167 deletions
-11
View File
@@ -19,22 +19,11 @@
<activity
android:name=".ui.MainActivity"
android:exported="true"
android:launchMode="singleTask"
android:theme="@style/Theme.PlanifyMobile">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<!-- Callback OAuth2 PKCE depuis le Custom Tab Keycloak -->
<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="com.planify.mobile"
android:host="auth"
android:pathPrefix="/callback" />
</intent-filter>
</activity>
<receiver
@@ -2,7 +2,6 @@ package com.planify.mobile.data.bonsai
import android.content.Context
import android.content.SharedPreferences
import android.net.Uri
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import dagger.hilt.android.qualifiers.ApplicationContext
@@ -14,8 +13,6 @@ import okhttp3.FormBody
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.Base64
import javax.inject.Inject
import javax.inject.Singleton
@@ -54,43 +51,12 @@ class BonsaiAuthManager @Inject constructor(
fun getUsername(): String = prefs.getString(KEY_USERNAME, "") ?: ""
fun getAuthHeader(): String? = prefs.getString(KEY_ACCESS_TOKEN, null)?.let { "Bearer $it" }
fun buildAuthUrl(): String {
val verifier = generateCodeVerifier()
val challenge = generateCodeChallenge(verifier)
val state = generateState()
prefs.edit()
.putString(KEY_PKCE_VERIFIER, verifier)
.putString(KEY_OAUTH_STATE, state)
.apply()
return Uri.parse("$KEYCLOAK_BASE/realms/$REALM/protocol/openid-connect/auth")
.buildUpon()
.appendQueryParameter("client_id", CLIENT_ID)
.appendQueryParameter("response_type", "code")
.appendQueryParameter("redirect_uri", REDIRECT_URI)
.appendQueryParameter("scope", "openid profile email")
.appendQueryParameter("code_challenge", challenge)
.appendQueryParameter("code_challenge_method", "S256")
.appendQueryParameter("state", state)
.build()
.toString()
}
suspend fun exchangeCode(code: String, state: String): LoginResult = withContext(Dispatchers.IO) {
val storedState = prefs.getString(KEY_OAUTH_STATE, null)
if (storedState == null || storedState != state) {
return@withContext LoginResult.Failure("Erreur de sécurité : état invalide")
}
val verifier = prefs.getString(KEY_PKCE_VERIFIER, null)
?: return@withContext LoginResult.Failure("PKCE verifier manquant")
suspend fun login(username: String, password: String): LoginResult = withContext(Dispatchers.IO) {
val body = FormBody.Builder()
.add("grant_type", "authorization_code")
.add("grant_type", "password")
.add("client_id", CLIENT_ID)
.add("code", code)
.add("redirect_uri", REDIRECT_URI)
.add("code_verifier", verifier)
.add("username", username)
.add("password", password)
.build()
val request = Request.Builder()
@@ -105,13 +71,9 @@ class BonsaiAuthManager @Inject constructor(
val detail = runCatching {
JSONObject(raw).optString("error_description", raw)
}.getOrDefault(raw)
return@withContext LoginResult.Failure("HTTP ${response.code}: $detail")
return@withContext LoginResult.Failure(detail)
}
saveTokens(JSONObject(raw))
prefs.edit()
.remove(KEY_PKCE_VERIFIER)
.remove(KEY_OAUTH_STATE)
.apply()
LoginResult.Success
}
}.getOrElse { LoginResult.Failure(it.message ?: "Erreur réseau") }
@@ -157,13 +119,12 @@ class BonsaiAuthManager @Inject constructor(
val accessToken = json.getString("access_token")
val refreshToken = json.optString("refresh_token", "")
val expiresIn = json.optLong("expires_in", 300L)
val username = extractUsername(accessToken)
prefs.edit()
.putString(KEY_ACCESS_TOKEN, accessToken)
.putString(KEY_REFRESH_TOKEN, refreshToken.ifBlank { null })
.putLong(KEY_EXPIRES_AT, System.currentTimeMillis() + expiresIn * 1000L)
.putString(KEY_USERNAME, username)
.putString(KEY_USERNAME, extractUsername(accessToken))
.apply()
_isAuthenticated.value = true
@@ -177,27 +138,8 @@ class BonsaiAuthManager @Inject constructor(
json.optString("preferred_username", json.optString("sub", ""))
}.getOrDefault("")
private fun generateCodeVerifier(): String {
val bytes = ByteArray(32)
SecureRandom().nextBytes(bytes)
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
}
private fun generateCodeChallenge(verifier: String): String {
val bytes = MessageDigest.getInstance("SHA-256")
.digest(verifier.toByteArray(Charsets.US_ASCII))
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
}
private fun generateState(): String {
val bytes = ByteArray(16)
SecureRandom().nextBytes(bytes)
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
}
companion object {
const val DEFAULT_API_URL = "https://bonsai.goutailler-olivier.com/api"
const val REDIRECT_URI = "com.planify.mobile://auth/callback"
private const val KEYCLOAK_BASE = "https://auth.goutailler-olivier.com"
private const val REALM = "bonsai"
private const val CLIENT_ID = "bonsai-webapp"
@@ -205,7 +147,5 @@ class BonsaiAuthManager @Inject constructor(
private const val KEY_REFRESH_TOKEN = "refresh_token"
private const val KEY_EXPIRES_AT = "expires_at"
private const val KEY_USERNAME = "username"
private const val KEY_PKCE_VERIFIER = "pkce_verifier"
private const val KEY_OAUTH_STATE = "oauth_state"
}
}
@@ -1,6 +1,5 @@
package com.planify.mobile.ui
import android.content.Intent
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
@@ -22,7 +21,6 @@ class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
handleIntent(intent)
setContent {
PlanifyTheme {
val status by authViewModel.status.collectAsState()
@@ -33,25 +31,4 @@ class MainActivity : ComponentActivity() {
}
}
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
handleIntent(intent)
}
private fun handleIntent(intent: Intent) {
val data = intent.data ?: return
if (data.scheme == "com.planify.mobile" && data.host == "auth") {
val code = data.getQueryParameter("code")
val state = data.getQueryParameter("state")
if (code != null && state != null) {
authViewModel.handleOAuthCallback(code, state)
} else {
val error = data.getQueryParameter("error_description")
?: data.getQueryParameter("error")
?: "Connexion annulée"
authViewModel.handleOAuthError(error)
}
}
}
}
@@ -7,10 +7,8 @@ import com.planify.mobile.data.bonsai.BonsaiSyncManager
import com.planify.mobile.data.bonsai.LoginResult
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import javax.inject.Inject
@@ -18,7 +16,7 @@ sealed class AuthStatus {
object Checking : AuthStatus()
object NotAuthenticated : AuthStatus()
data class Authenticated(val username: String) : AuthStatus()
data class Error(val message: String) : AuthStatus()
data class LoginError(val message: String) : AuthStatus()
}
@HiltViewModel
@@ -40,8 +38,7 @@ class AuthViewModel @Inject constructor(
_status.value = AuthStatus.NotAuthenticated
return@launch
}
val refreshOk = authManager.refreshIfNeeded()
if (refreshOk) {
if (authManager.refreshIfNeeded()) {
_status.value = AuthStatus.Authenticated(authManager.getUsername())
syncManager.sync()
} else {
@@ -51,18 +48,16 @@ class AuthViewModel @Inject constructor(
}
}
fun buildAuthUrl(): String = authManager.buildAuthUrl()
fun handleOAuthCallback(code: String, state: String) {
fun login(username: String, password: String) {
_status.value = AuthStatus.Checking
viewModelScope.launch {
when (val result = authManager.exchangeCode(code, state)) {
when (val result = authManager.login(username, password)) {
is LoginResult.Success -> {
_status.value = AuthStatus.Authenticated(authManager.getUsername())
syncManager.sync()
}
is LoginResult.Failure -> {
_status.value = AuthStatus.Error(result.message)
_status.value = AuthStatus.LoginError(result.message)
}
}
}
@@ -76,8 +71,4 @@ class AuthViewModel @Inject constructor(
fun clearError() {
_status.value = AuthStatus.NotAuthenticated
}
fun handleOAuthError(message: String) {
_status.value = AuthStatus.Error(message)
}
}
@@ -1,7 +1,5 @@
package com.planify.mobile.ui.auth
import android.net.Uri
import androidx.browser.customtabs.CustomTabsIntent
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
@@ -10,50 +8,81 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Visibility
import androidx.compose.material.icons.outlined.VisibilityOff
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.OutlinedTextFieldDefaults
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.hilt.navigation.compose.hiltViewModel
import com.planify.mobile.R
private val MintBackground = Color(0xFFF0FDF4)
private val BonsaiGreen = Color(0xFF38A169)
private val FieldBorder = Color(0xFFD1D5DB)
private val LabelColor = Color(0xFF374151)
private val TitleColor = Color(0xFF111827)
@Composable
fun LoginScreen(viewModel: AuthViewModel = hiltViewModel()) {
fun LoginScreen(viewModel: AuthViewModel) {
val status by viewModel.status.collectAsState()
val context = LocalContext.current
val isLoading = status is AuthStatus.Checking
val errorMessage = (status as? AuthStatus.LoginError)?.message
var username by rememberSaveable { mutableStateOf("") }
var password by rememberSaveable { mutableStateOf("") }
var passwordVisible by remember { mutableStateOf(false) }
val passwordFocus = remember { FocusRequester() }
Box(
modifier = Modifier
.fillMaxSize()
.background(MintBackground),
.background(MintBackground)
.imePadding(),
contentAlignment = Alignment.Center,
) {
Surface(
shape = RoundedCornerShape(16.dp),
shadowElevation = 4.dp,
shadowElevation = 6.dp,
color = Color.White,
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 32.dp),
.padding(horizontal = 28.dp)
.verticalScroll(rememberScrollState()),
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
@@ -65,13 +94,13 @@ fun LoginScreen(viewModel: AuthViewModel = hiltViewModel()) {
modifier = Modifier.size(72.dp),
)
Spacer(Modifier.height(12.dp))
Spacer(Modifier.height(10.dp))
Text(
text = "Bonsai",
fontWeight = FontWeight.Bold,
fontSize = 22.sp,
color = Color(0xFF111827),
color = TitleColor,
)
Spacer(Modifier.height(4.dp))
@@ -80,57 +109,126 @@ fun LoginScreen(viewModel: AuthViewModel = hiltViewModel()) {
text = "Connexion",
fontWeight = FontWeight.SemiBold,
fontSize = 18.sp,
color = Color(0xFF374151),
color = LabelColor,
)
Spacer(Modifier.height(24.dp))
when (status) {
is AuthStatus.Checking -> {
CircularProgressIndicator(color = BonsaiGreen)
Spacer(Modifier.height(8.dp))
Text(
text = "Vérification…",
style = MaterialTheme.typography.bodySmall,
color = Color(0xFF6B7280),
)
}
// Username field
Text(
text = "Email ou nom d'utilisateur",
style = MaterialTheme.typography.bodySmall,
color = LabelColor,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 4.dp),
)
OutlinedTextField(
value = username,
onValueChange = { username = it },
modifier = Modifier.fillMaxWidth(),
singleLine = true,
enabled = !isLoading,
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Email,
imeAction = ImeAction.Next,
),
keyboardActions = KeyboardActions(
onNext = { passwordFocus.requestFocus() }
),
colors = OutlinedTextFieldDefaults.colors(
unfocusedBorderColor = FieldBorder,
focusedBorderColor = BonsaiGreen,
),
shape = RoundedCornerShape(6.dp),
)
is AuthStatus.Error -> {
Text(
text = (status as AuthStatus.Error).message,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.padding(bottom = 12.dp),
)
LoginButton {
val url = viewModel.buildAuthUrl()
CustomTabsIntent.Builder().setShowTitle(true).build()
.launchUrl(context, Uri.parse(url))
}
}
Spacer(Modifier.height(12.dp))
else -> {
LoginButton {
val url = viewModel.buildAuthUrl()
CustomTabsIntent.Builder().setShowTitle(true).build()
.launchUrl(context, Uri.parse(url))
// Password field
Text(
text = "Mot de passe",
style = MaterialTheme.typography.bodySmall,
color = LabelColor,
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 4.dp),
)
OutlinedTextField(
value = password,
onValueChange = { password = it },
modifier = Modifier
.fillMaxWidth()
.focusRequester(passwordFocus),
singleLine = true,
enabled = !isLoading,
visualTransformation = if (passwordVisible) VisualTransformation.None
else PasswordVisualTransformation(),
keyboardOptions = KeyboardOptions(
keyboardType = KeyboardType.Password,
imeAction = ImeAction.Done,
),
keyboardActions = KeyboardActions(
onDone = {
if (username.isNotBlank() && password.isNotBlank()) {
viewModel.login(username.trim(), password)
}
}
),
trailingIcon = {
IconButton(onClick = { passwordVisible = !passwordVisible }) {
Icon(
imageVector = if (passwordVisible) Icons.Outlined.Visibility
else Icons.Outlined.VisibilityOff,
contentDescription = if (passwordVisible) "Masquer" else "Afficher",
tint = Color(0xFF6B7280),
)
}
},
colors = OutlinedTextFieldDefaults.colors(
unfocusedBorderColor = FieldBorder,
focusedBorderColor = BonsaiGreen,
),
shape = RoundedCornerShape(6.dp),
)
errorMessage?.let {
Spacer(Modifier.height(8.dp))
Text(
text = it,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodySmall,
modifier = Modifier.fillMaxWidth(),
)
}
Spacer(Modifier.height(20.dp))
Button(
onClick = { viewModel.login(username.trim(), password) },
enabled = !isLoading && username.isNotBlank() && password.isNotBlank(),
modifier = Modifier
.fillMaxWidth()
.height(48.dp),
colors = ButtonDefaults.buttonColors(containerColor = BonsaiGreen),
shape = RoundedCornerShape(6.dp),
) {
if (isLoading) {
CircularProgressIndicator(
modifier = Modifier.size(20.dp),
color = Color.White,
strokeWidth = 2.dp,
)
} else {
Text(
text = "Se connecter",
color = Color.White,
fontWeight = FontWeight.Medium,
fontSize = 15.sp,
)
}
}
}
}
}
}
@Composable
private fun LoginButton(onClick: () -> Unit) {
Button(
onClick = onClick,
modifier = Modifier.fillMaxWidth(),
colors = ButtonDefaults.buttonColors(containerColor = BonsaiGreen),
shape = RoundedCornerShape(8.dp),
) {
Text("Se connecter", color = Color.White, fontWeight = FontWeight.Medium)
}
}