From 08f8267dcc5f9609a68e84e24f332cf84f3559d1 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 9 Sep 2026 18:33:36 +0200 Subject: [PATCH] fix(auth): SSO-Korrekturen aus dem Review + stiller Token-Refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../calendarr/data/OidcAuthenticator.kt | 40 +++++++++++ .../scarriffle/calendarr/ui/MainViewModel.kt | 67 ++++++++++++++++++- 2 files changed, 106 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/scarriffle/calendarr/data/OidcAuthenticator.kt b/app/src/main/java/com/scarriffle/calendarr/data/OidcAuthenticator.kt index 6831060..a00343d 100644 --- a/app/src/main/java/com/scarriffle/calendarr/data/OidcAuthenticator.kt +++ b/app/src/main/java/com/scarriffle/calendarr/data/OidcAuthenticator.kt @@ -21,6 +21,14 @@ import kotlin.coroutines.resumeWithException /** The app is registered as a *public* client — no secret is ever sent. */ private const val REDIRECT_URI = "com.scarriffle.calendarr:/oauth2redirect" +/** Outcome of a silent renewal against the identity provider. */ +data class RenewedTokens( + val idToken: String, + val accessToken: String?, + val nonce: String?, + val serialisedState: String, +) + /** * Drives the OpenID Connect Authorization Code Flow with PKCE against the * identity provider, using AppAuth and a Chrome Custom Tab. @@ -106,6 +114,38 @@ class OidcAuthenticator @Inject constructor( return response to tokens } + /** + * Ask the provider for a fresh ID token using a stored [AuthState]. + * + * Returns null when there is no session to renew. The refreshed state is + * handed back too, because the provider may have rotated the refresh token. + */ + suspend fun renewIdToken(serialisedState: String): RenewedTokens? { + val state = runCatching { AuthState.jsonDeserialize(serialisedState) }.getOrNull() + ?: return null + + val tokens: Pair = suspendCancellableCoroutine { cont -> + state.performActionWithFreshTokens(service()) { accessToken, idToken, ex -> + if (ex != null) { + cont.resumeWithException(ex) + } else { + cont.resume(idToken to accessToken) + } + } + } + + val idToken = tokens.first ?: return null + return RenewedTokens( + idToken = idToken, + accessToken = tokens.second, + // The original request rides along inside the state, so the nonce + // survives. A refreshed token often drops the claim, which the + // server tolerates — but when it keeps it, it has to match. + nonce = state.lastAuthorizationResponse?.request?.nonce, + serialisedState = state.jsonSerializeString(), + ) + } + /** * Serialised AuthState (including the refresh token) for secure storage. * diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/MainViewModel.kt b/app/src/main/java/com/scarriffle/calendarr/ui/MainViewModel.kt index 5303003..36beed2 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/MainViewModel.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/MainViewModel.kt @@ -4,6 +4,7 @@ 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 @@ -22,6 +23,7 @@ class MainViewModel @Inject constructor( private val settingsStore: SettingsStore, private val repository: CalendarRepository, private val apiProvider: ApiProvider, + private val oidc: OidcAuthenticator, ) : ViewModel() { private val _route = MutableStateFlow(computeRoute()) @@ -35,7 +37,44 @@ class MainViewModel @Inject constructor( val isAdmin: Boolean get() = credentialStore.isAdmin init { - if (_route.value == AppRoute.MAIN) refreshSettings() + 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 { @@ -76,4 +115,30 @@ class MainViewModel @Inject constructor( 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 + } }