This repository has been archived on 2026-09-14. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
Calendarr-Android/app/src/main/java/com/scarriffle/calendarr/ui/MainViewModel.kt
T
ScarriffleandClaude Opus 5 08f8267dcc fix(auth): SSO-Korrekturen aus dem Review + stiller Token-Refresh
Drei Fehler, die den Flow gebrochen haetten:

- Ein Abbruch im Browser liefert bei AppAuth ein regulaeres Ergebnis mit
  USER_CANCELED_AUTH_FLOW, nicht null. Der bisherige Null-Check lief ins
  Leere und "Zurueck" endete in einer roten Fehlermeldung.
- launch(intent) lag ausserhalb von runCatching. Verlaesst der Nutzer den
  Screen waehrend der Discovery, ist der Result-Launcher abgemeldet und die
  IllegalStateException haette die App beendet.
- Nonce und Provider lagen nur in ViewModel-Feldern. Der Custom Tab laeuft
  in eigener Task, dieser Prozess wird dabei routinemaessig getoetet — die
  Anmeldung waere nach erfolgreichem Login beim IdP gescheitert. Der Nonce
  kommt jetzt aus AuthorizationResponse.request, Provider und Client-ID
  werden vor dem Start persistiert.

Weiter: AuthState.update verlangt genau eines von (Response, Exception) und
haette bei null/null geworfen; onCleared() verwarf den @Singleton-Service
fuer die zweite AuthViewModel-Instanz mit.

Neu ist der geforderte Hintergrund-Refresh: laeuft das Calendarr-Token
binnen 24 h ab, wird beim Start ueber den Provider-Refresh-Token ein frisches
ID-Token geholt und getauscht — ohne Browser. Best effort; schlaegt es fehl,
bleibt das vorhandene Token in Kraft.

Ausserdem: Scopes kommen ausschliesslich vom Server (mobile_scopes), Spinner
pro Provider statt global, fehlender Browser als eigener Fehlerfall, Issuer
wird validiert, keine namenlosen Identitaeten mehr.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 18:33:36 +02:00

145 lines
5.1 KiB
Kotlin

package com.scarriffle.calendarr.ui
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.scarriffle.calendarr.data.CalendarRepository
import com.scarriffle.calendarr.data.CredentialStore
import com.scarriffle.calendarr.data.OidcAuthenticator
import com.scarriffle.calendarr.data.SettingsStore
import com.scarriffle.calendarr.data.remote.ApiProvider
import com.scarriffle.calendarr.domain.model.AppSettings
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import javax.inject.Inject
enum class AppRoute { SETUP, LOGIN, MAIN }
@HiltViewModel
class MainViewModel @Inject constructor(
private val credentialStore: CredentialStore,
private val settingsStore: SettingsStore,
private val repository: CalendarRepository,
private val apiProvider: ApiProvider,
private val oidc: OidcAuthenticator,
) : ViewModel() {
private val _route = MutableStateFlow(computeRoute())
val route: StateFlow<AppRoute> = _route.asStateFlow()
private val _settings = MutableStateFlow(settingsStore.loadSettings())
val settings: StateFlow<AppSettings> = _settings.asStateFlow()
val serverUrl: String get() = credentialStore.serverUrl ?: ""
val username: String get() = credentialStore.username ?: ""
val isAdmin: Boolean get() = credentialStore.isAdmin
init {
if (_route.value == AppRoute.MAIN) {
refreshSettings()
renewSsoSessionIfNeeded()
}
}
/**
* Keep an SSO session alive without another trip through the browser.
*
* Calendarr's own token simply expires; what `offline_access` buys is a
* refresh token at the *provider*. So renewal means: fresh ID token from
* the provider, then trade it for a fresh Calendarr token.
*
* Best effort — on any failure the existing token stays in place and the
* user notices nothing until it genuinely expires.
*/
private fun renewSsoSessionIfNeeded() {
val state = credentialStore.oidcState ?: return
val providerKey = credentialStore.oidcProvider ?: return
val clientId = credentialStore.oidcClientId ?: return
val base = credentialStore.serverUrl ?: return
val token = credentialStore.token ?: return
if (!expiresSoon(token)) return
viewModelScope.launch {
runCatching {
val renewed = oidc.renewIdToken(state) ?: return@runCatching
repository.exchangeOidc(
baseUrl = base,
provider = providerKey,
clientId = clientId,
idToken = renewed.idToken,
accessToken = renewed.accessToken,
nonce = renewed.nonce,
)
credentialStore.saveOidcSession(renewed.serialisedState, providerKey, clientId)
}
}
}
private fun computeRoute(): AppRoute = when {
!credentialStore.isConfigured -> AppRoute.SETUP
!credentialStore.isLoggedIn -> AppRoute.LOGIN
else -> AppRoute.MAIN
}
fun onServerConfigured() { _route.value = computeRoute() }
fun onLoggedIn() {
_route.value = AppRoute.MAIN
refreshSettings()
}
fun logout() {
credentialStore.clearToken()
_route.value = computeRoute()
}
fun switchServer() {
credentialStore.clearAll()
apiProvider.invalidate()
_route.value = computeRoute()
}
/** Pull settings from the server and merge them with local values honouring
* each setting's sync flag (synced keys take the server value). */
fun refreshSettings() {
viewModelScope.launch {
runCatching { repository.getSettings() }.onSuccess { s ->
_settings.value = settingsStore.applyServerPull(s)
}
}
}
fun applyLocalSettings(s: AppSettings) {
settingsStore.saveSettings(s)
_settings.value = s
}
/**
* True when the Calendarr JWT expires within [RENEW_WITHIN_MS].
*
* The payload is read without verifying the signature — this only decides
* *whether* to renew, and the server validates the token on every request
* regardless. A token we cannot parse is left alone.
*/
private fun expiresSoon(jwt: String): Boolean {
val parts = jwt.split(".")
if (parts.size != 3) return false
return runCatching {
val payload = android.util.Base64.decode(
parts[1],
android.util.Base64.URL_SAFE or android.util.Base64.NO_PADDING or
android.util.Base64.NO_WRAP,
)
val exp = org.json.JSONObject(String(payload)).optLong("exp", 0L)
exp > 0L && (exp * 1000L) - System.currentTimeMillis() < RENEW_WITHIN_MS
}.getOrDefault(false)
}
private companion object {
/** Renew when less than a day of the Calendarr token's life is left. */
const val RENEW_WITHIN_MS = 24L * 60 * 60 * 1000
}
}