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>
This commit is contained in:
Scarriffle
2026-09-09 18:33:36 +02:00
co-authored by Claude Opus 5
parent 8b0af0f44a
commit 08f8267dcc
2 changed files with 106 additions and 1 deletions
@@ -21,6 +21,14 @@ import kotlin.coroutines.resumeWithException
/** The app is registered as a *public* client — no secret is ever sent. */ /** The app is registered as a *public* client — no secret is ever sent. */
private const val REDIRECT_URI = "com.scarriffle.calendarr:/oauth2redirect" 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 * Drives the OpenID Connect Authorization Code Flow with PKCE against the
* identity provider, using AppAuth and a Chrome Custom Tab. * identity provider, using AppAuth and a Chrome Custom Tab.
@@ -106,6 +114,38 @@ class OidcAuthenticator @Inject constructor(
return response to tokens 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<String?, String?> = 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. * Serialised AuthState (including the refresh token) for secure storage.
* *
@@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.scarriffle.calendarr.data.CalendarRepository import com.scarriffle.calendarr.data.CalendarRepository
import com.scarriffle.calendarr.data.CredentialStore import com.scarriffle.calendarr.data.CredentialStore
import com.scarriffle.calendarr.data.OidcAuthenticator
import com.scarriffle.calendarr.data.SettingsStore import com.scarriffle.calendarr.data.SettingsStore
import com.scarriffle.calendarr.data.remote.ApiProvider import com.scarriffle.calendarr.data.remote.ApiProvider
import com.scarriffle.calendarr.domain.model.AppSettings import com.scarriffle.calendarr.domain.model.AppSettings
@@ -22,6 +23,7 @@ class MainViewModel @Inject constructor(
private val settingsStore: SettingsStore, private val settingsStore: SettingsStore,
private val repository: CalendarRepository, private val repository: CalendarRepository,
private val apiProvider: ApiProvider, private val apiProvider: ApiProvider,
private val oidc: OidcAuthenticator,
) : ViewModel() { ) : ViewModel() {
private val _route = MutableStateFlow(computeRoute()) private val _route = MutableStateFlow(computeRoute())
@@ -35,7 +37,44 @@ class MainViewModel @Inject constructor(
val isAdmin: Boolean get() = credentialStore.isAdmin val isAdmin: Boolean get() = credentialStore.isAdmin
init { 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 { private fun computeRoute(): AppRoute = when {
@@ -76,4 +115,30 @@ class MainViewModel @Inject constructor(
settingsStore.saveSettings(s) settingsStore.saveSettings(s)
_settings.value = 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
}
} }