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. */
|
/** 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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user