Archived
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:
co-authored by
Claude Opus 5
parent
8b0af0f44a
commit
08f8267dcc
@@ -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<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.
|
||||
*
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user