From 8b0af0f44acee21abf05dd77c969e3578625a2a4 Mon Sep 17 00:00:00 2001 From: Scarriffle Date: Wed, 9 Sep 2026 18:25:15 +0200 Subject: [PATCH] feat(auth): SSO-Anmeldung ueber OpenID Connect (AppAuth) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Zusaetzlicher Anmeldeweg neben Benutzername/Passwort. Der Login-Screen holt die konfigurierten Provider von GET /api/auth/oidc/providers; ohne Provider oder gegen einen aelteren Server bleibt der Screen unveraendert. Die App laeuft als Public Client: Authorization Code Flow mit PKCE in einem Chrome Custom Tab (nie ein WebView), ohne Client Secret. Das ID-Token wird anschliessend an POST /api/auth/oidc/exchange gegen ein Calendarr-Token getauscht; die Antwort hat dieselbe Form wie der Passwort-Login, sodass der bestehende Session-Pfad unveraendert weiterlaeuft. - OidcAuthenticator kapselt Discovery, PKCE-Request und Code-Tausch. - Die angefragten Scopes kommen vom Server (mobile_scopes) — ein Scope, den der Provider nicht kennt, wuerde die ganze Anfrage scheitern lassen. - AuthState mit dem Refresh-Token liegt in den EncryptedSharedPreferences, nie im Klartext, und wird beim Logout mitgeloescht. - Provider und Client-ID werden vor dem Browser-Start persistiert, weil der Custom Tab in einer eigenen Task laeuft und dieser Prozess dabei getoetet werden kann; der Nonce kommt aus AuthorizationResponse.request, das AppAuth ueber den Redirect zurueckreicht. - Abbruch im Browser meldet AppAuth als regulaeres Ergebnis mit USER_CANCELED_AUTH_FLOW und wird still behandelt, nicht als Fehler. Co-Authored-By: Claude Opus 5 --- app/build.gradle.kts | 10 ++ .../calendarr/data/CalendarRepository.kt | 85 +++++++++++- .../calendarr/data/CredentialStore.kt | 33 +++++ .../calendarr/data/OidcAuthenticator.kt | 120 ++++++++++++++++ .../calendarr/data/remote/ApiException.kt | 8 ++ .../calendarr/data/remote/CalendarrApi.kt | 9 ++ .../calendarr/domain/model/OidcProvider.kt | 13 ++ .../java/com/scarriffle/calendarr/ui/L10n.kt | 30 ++++ .../calendarr/ui/auth/AuthViewModel.kt | 131 +++++++++++++++++- .../calendarr/ui/auth/LoginScreen.kt | 48 +++++++ 10 files changed, 482 insertions(+), 5 deletions(-) create mode 100644 app/src/main/java/com/scarriffle/calendarr/data/OidcAuthenticator.kt create mode 100644 app/src/main/java/com/scarriffle/calendarr/domain/model/OidcProvider.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2f50d01..5ea04f1 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -29,6 +29,13 @@ android { testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { useSupportLibrary = true } + + // AppAuth merges its RedirectUriReceiverActivity into the manifest and + // needs the custom scheme for the OIDC redirect. Reverse-DNS on the + // application id, per RFC 8252; the same value must be registered as a + // redirect URI in the identity provider: + // com.scarriffle.calendarr:/oauth2redirect + manifestPlaceholders["appAuthRedirectScheme"] = "com.scarriffle.calendarr" } signingConfigs { @@ -109,6 +116,9 @@ dependencies { // Secure credential storage implementation("androidx.security:security-crypto:1.1.0-alpha06") + // OIDC / SSO — Authorization Code Flow with PKCE in a Custom Tab + implementation("net.openid:appauth:0.11.1") + // Async implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.7.3") diff --git a/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt b/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt index 96c5860..32b1e01 100644 --- a/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt +++ b/app/src/main/java/com/scarriffle/calendarr/data/CalendarRepository.kt @@ -1,6 +1,7 @@ package com.scarriffle.calendarr.data import com.scarriffle.calendarr.data.remote.ApiException +import com.scarriffle.calendarr.data.remote.OidcException import com.scarriffle.calendarr.data.remote.ApiProvider import com.scarriffle.calendarr.data.remote.TwoFactorRequiredException import com.scarriffle.calendarr.data.remote.UnauthorizedException @@ -18,6 +19,7 @@ import com.scarriffle.calendarr.domain.model.GoogleAccount import com.scarriffle.calendarr.domain.model.HomeAssistantAccount import com.scarriffle.calendarr.domain.model.ICalSubscription import com.scarriffle.calendarr.domain.model.LocalCalendar +import com.scarriffle.calendarr.domain.model.OidcProvider import com.scarriffle.calendarr.domain.model.UserProfile import com.scarriffle.calendarr.domain.model.WritableCalendar import com.scarriffle.calendarr.util.Dates @@ -100,10 +102,89 @@ class CalendarRepository @Inject constructor( if (!resp.isSuccessful) throw ApiException(errorDetail(resp.errorBody(), resp.code())) val json = JSONObject(resp.body()?.string() ?: throw ApiException("Leere Antwort")) + persistLogin(baseUrl, json, fallbackUsername = username) + } + + // ---- Single sign-on (OpenID Connect) ---- + + /** Providers configured on the server. Empty list when SSO is off. */ + suspend fun oidcProviders(baseUrl: String): List = withContext(Dispatchers.IO) { + val resp = apiProvider.apiFor(baseUrl).oidcProviders() + if (!resp.isSuccessful) return@withContext emptyList() + val raw = resp.body()?.string() ?: return@withContext emptyList() + runCatching { + val json = JSONObject(raw) + if (!json.optBoolean("enabled", false)) return@runCatching emptyList() + val arr = json.optJSONArray("providers") ?: return@runCatching emptyList() + (0 until arr.length()).mapNotNull { i -> + val o = arr.optJSONObject(i) ?: return@mapNotNull null + // Without a mobile client id the app cannot start a flow. + val clientId = o.optString("mobile_client_id").takeIf { it.isNotBlank() } + ?: return@mapNotNull null + // Without an issuer the flow can only fail later with a + // misleading "unreachable" — drop the provider here instead. + val issuer = o.optString("issuer").takeIf { it.isNotBlank() } + ?: return@mapNotNull null + OidcProvider( + key = o.optString("key"), + name = o.optString("name").takeIf { it.isNotBlank() } ?: o.optString("key"), + issuer = issuer, + // The server decides the scopes (it knows whether the + // provider grants offline_access); the app never adds any. + scopes = o.optString("mobile_scopes").takeIf { it.isNotBlank() } + ?: o.optString("scopes").takeIf { it.isNotBlank() } + ?: "openid profile email", + mobileClientId = clientId, + ) + } + }.getOrDefault(emptyList()) + } + + /** + * Trade a verified provider ID token for a Calendarr token. + * + * The app is a public client: the request carries no secret, only the id + * token the provider issued for [clientId] plus the [nonce] AppAuth bound + * into the flow. + */ + suspend fun exchangeOidc( + baseUrl: String, + provider: String, + clientId: String, + idToken: String, + accessToken: String?, + nonce: String?, + ): LoginResult = withContext(Dispatchers.IO) { + val body = jsonBody( + "provider" to provider, + "client_id" to clientId, + "id_token" to idToken, + "access_token" to accessToken, + "nonce" to nonce, + ) + val resp = apiProvider.apiFor(baseUrl).oidcExchange(body) + if (!resp.isSuccessful) { + // The server answers with a stable slug in `detail`; map the ones a + // user can act on, and fall back to the raw detail otherwise. + val detail = runCatching { + JSONObject(resp.errorBody()?.string() ?: "").optString("detail") + }.getOrNull().orEmpty() + throw OidcException(detail.takeIf { it.isNotBlank() } ?: "oidc_failed") + } + val json = JSONObject(resp.body()?.string() ?: throw ApiException("Leere Antwort")) + persistLogin(baseUrl, json, fallbackUsername = null) + } + + /** Shared tail of [login] and [exchangeOidc]: read the user, persist, reset. */ + private fun persistLogin(baseUrl: String, json: JSONObject, fallbackUsername: String?): LoginResult { val token = json.optString("access_token").takeIf { it.isNotBlank() } ?: throw ApiException("Antwort konnte nicht verarbeitet werden") val user = json.optJSONObject("user") - val uname = user?.optString("username") ?: username + // A blank username would be persisted as a blank display name too, so + // refuse rather than store a nameless identity. + val uname = user?.optString("username")?.takeIf { it.isNotBlank() } + ?: fallbackUsername + ?: throw ApiException("Antwort konnte nicht verarbeitet werden") val isAdmin = user?.optBoolean("is_admin", false) ?: false val uid = user?.optInt("id", 0) ?: 0 val displayName = user?.optString("display_name")?.takeIf { it.isNotBlank() } ?: uname @@ -111,7 +192,7 @@ class CalendarRepository @Inject constructor( credentialStore.serverUrl = ApiProvider.normalize(baseUrl) credentialStore.saveLogin(token, uname, isAdmin, uid, displayName) apiProvider.invalidate() - LoginResult(token, uname, isAdmin) + return LoginResult(token, uname, isAdmin) } // ---- Settings / profile ---- diff --git a/app/src/main/java/com/scarriffle/calendarr/data/CredentialStore.kt b/app/src/main/java/com/scarriffle/calendarr/data/CredentialStore.kt index 07207b4..3f502a2 100644 --- a/app/src/main/java/com/scarriffle/calendarr/data/CredentialStore.kt +++ b/app/src/main/java/com/scarriffle/calendarr/data/CredentialStore.kt @@ -54,6 +54,24 @@ class CredentialStore @Inject constructor( get() = prefs.getString(KEY_DISPLAY_NAME, null) set(value) = prefs.edit().putString(KEY_DISPLAY_NAME, value).apply() + /** + * Serialised AppAuth [net.openid.appauth.AuthState] for the SSO session — + * holds the provider's refresh token, so it must stay in the encrypted + * store and never in plain preferences. + */ + var oidcState: String? + get() = prefs.getString(KEY_OIDC_STATE, null) + set(value) = prefs.edit().putString(KEY_OIDC_STATE, value).apply() + + /** Provider key and client id the current SSO session was obtained with. */ + var oidcProvider: String? + get() = prefs.getString(KEY_OIDC_PROVIDER, null) + set(value) = prefs.edit().putString(KEY_OIDC_PROVIDER, value).apply() + + var oidcClientId: String? + get() = prefs.getString(KEY_OIDC_CLIENT_ID, null) + set(value) = prefs.edit().putString(KEY_OIDC_CLIENT_ID, value).apply() + /** True once a server URL has been entered (setup step complete). */ val isConfigured: Boolean get() = !serverUrl.isNullOrBlank() @@ -70,12 +88,24 @@ class CredentialStore @Inject constructor( .apply() } + /** Persist the SSO session alongside the Calendarr token. */ + fun saveOidcSession(state: String, provider: String, clientId: String) { + prefs.edit() + .putString(KEY_OIDC_STATE, state) + .putString(KEY_OIDC_PROVIDER, provider) + .putString(KEY_OIDC_CLIENT_ID, clientId) + .apply() + } + /** Clear the token (logout) but keep the server URL. */ fun clearToken() { prefs.edit() .remove(KEY_TOKEN) .remove(KEY_USERNAME) .remove(KEY_IS_ADMIN) + .remove(KEY_OIDC_STATE) + .remove(KEY_OIDC_PROVIDER) + .remove(KEY_OIDC_CLIENT_ID) .apply() } @@ -91,5 +121,8 @@ class CredentialStore @Inject constructor( const val KEY_IS_ADMIN = "is_admin" const val KEY_USER_ID = "user_id" const val KEY_DISPLAY_NAME = "display_name" + const val KEY_OIDC_STATE = "oidc_state" + const val KEY_OIDC_PROVIDER = "oidc_provider" + const val KEY_OIDC_CLIENT_ID = "oidc_client_id" } } diff --git a/app/src/main/java/com/scarriffle/calendarr/data/OidcAuthenticator.kt b/app/src/main/java/com/scarriffle/calendarr/data/OidcAuthenticator.kt new file mode 100644 index 0000000..6831060 --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/data/OidcAuthenticator.kt @@ -0,0 +1,120 @@ +package com.scarriffle.calendarr.data + +import android.content.Context +import android.content.Intent +import com.scarriffle.calendarr.domain.model.OidcProvider +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.suspendCancellableCoroutine +import net.openid.appauth.AuthState +import net.openid.appauth.AuthorizationException +import net.openid.appauth.AuthorizationRequest +import net.openid.appauth.AuthorizationResponse +import net.openid.appauth.AuthorizationService +import net.openid.appauth.AuthorizationServiceConfiguration +import net.openid.appauth.ResponseTypeValues +import net.openid.appauth.TokenResponse +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.resume +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" + +/** + * Drives the OpenID Connect Authorization Code Flow with PKCE against the + * identity provider, using AppAuth and a Chrome Custom Tab. + * + * Deliberately never uses a WebView: a custom tab keeps the credentials in the + * browser's own security context and lets the provider's session cookies work. + */ +@Singleton +class OidcAuthenticator @Inject constructor( + @ApplicationContext private val context: Context, +) { + + @Volatile + private var service: AuthorizationService? = null + + private fun service(): AuthorizationService = + service ?: synchronized(this) { + service ?: AuthorizationService(context).also { service = it } + } + + /** Fetch the provider's OpenID configuration from its issuer URL. */ + suspend fun discover(provider: OidcProvider): AuthorizationServiceConfiguration = + suspendCancellableCoroutine { cont -> + AuthorizationServiceConfiguration.fetchFromIssuer( + android.net.Uri.parse(provider.issuer) + ) { config, ex -> + when { + config != null -> cont.resume(config) + else -> cont.resumeWithException(ex ?: AuthorizationException.GeneralErrors.NETWORK_ERROR) + } + } + } + + /** + * Build the authorization request. AppAuth generates the PKCE verifier and + * the nonce itself; both are carried on the returned request, and the nonce + * has to travel to our server later so it can verify the ID token. + */ + fun buildRequest( + config: AuthorizationServiceConfiguration, + provider: OidcProvider, + ): AuthorizationRequest { + // Exactly what the server asked for — including offline_access when it + // configured that. A scope the provider does not know would make it + // reject the whole authorization request. + val scopes = buildSet { + addAll(provider.scopes.split(" ").filter { it.isNotBlank() }) + add("openid") + } + return AuthorizationRequest.Builder( + config, + requireNotNull(provider.mobileClientId) { "provider has no mobile client id" }, + ResponseTypeValues.CODE, + android.net.Uri.parse(REDIRECT_URI), + ) + .setScopes(scopes) + .build() + } + + /** Intent that opens the provider's login page in a Custom Tab. */ + fun authorizationIntent(request: AuthorizationRequest): Intent = + service().getAuthorizationRequestIntent(request) + + /** + * Complete the code exchange with the provider. + * + * PKCE means no client secret is involved: the verifier AppAuth kept from + * [buildRequest] is what proves this is the same app that started the flow. + */ + suspend fun exchangeCode(data: Intent): Pair { + val response = AuthorizationResponse.fromIntent(data) + val error = AuthorizationException.fromIntent(data) + if (response == null) throw error ?: AuthorizationException.GeneralErrors.NETWORK_ERROR + + val tokens: TokenResponse = suspendCancellableCoroutine { cont -> + service().performTokenRequest(response.createTokenExchangeRequest()) { tokens, ex -> + when { + tokens != null -> cont.resume(tokens) + else -> cont.resumeWithException(ex ?: AuthorizationException.GeneralErrors.NETWORK_ERROR) + } + } + } + return response to tokens + } + + /** + * Serialised AuthState (including the refresh token) for secure storage. + * + * Both arguments are non-null on purpose: `AuthState.update` asserts that + * exactly one of (response, exception) is set and throws otherwise. + */ + fun stateFor(response: AuthorizationResponse, tokens: TokenResponse): String = + AuthState().apply { + update(response, null) + update(tokens, null) + }.jsonSerializeString() +} diff --git a/app/src/main/java/com/scarriffle/calendarr/data/remote/ApiException.kt b/app/src/main/java/com/scarriffle/calendarr/data/remote/ApiException.kt index 07babf5..3db4719 100644 --- a/app/src/main/java/com/scarriffle/calendarr/data/remote/ApiException.kt +++ b/app/src/main/java/com/scarriffle/calendarr/data/remote/ApiException.kt @@ -32,3 +32,11 @@ fun Response<*>.ensureSuccess() { throw ApiException(errorDetail(errorBody(), code())) } } + + +/** + * An SSO exchange the server refused. [slug] is the stable machine-readable + * reason (e.g. `oidc_account_not_linked`), which the UI maps to a translated + * message; unknown slugs fall back to a generic one. + */ +class OidcException(val slug: String) : Exception(slug) diff --git a/app/src/main/java/com/scarriffle/calendarr/data/remote/CalendarrApi.kt b/app/src/main/java/com/scarriffle/calendarr/data/remote/CalendarrApi.kt index 4620af3..3b4d75d 100644 --- a/app/src/main/java/com/scarriffle/calendarr/data/remote/CalendarrApi.kt +++ b/app/src/main/java/com/scarriffle/calendarr/data/remote/CalendarrApi.kt @@ -37,6 +37,15 @@ interface CalendarrApi { @GET("api/auth/setup-required") suspend fun setupRequired(): Response + /** Configured OIDC providers. Public — used before login. */ + @GET("api/auth/oidc/providers") + suspend fun oidcProviders(): Response + + /** Trade a provider ID token for a Calendarr token. Same response shape as + * [login]. Public client: never send a client secret. */ + @POST("api/auth/oidc/exchange") + suspend fun oidcExchange(@Body body: RequestBody): Response + @POST("api/auth/login") suspend fun login(@Body body: RequestBody): Response diff --git a/app/src/main/java/com/scarriffle/calendarr/domain/model/OidcProvider.kt b/app/src/main/java/com/scarriffle/calendarr/domain/model/OidcProvider.kt new file mode 100644 index 0000000..7bcae1a --- /dev/null +++ b/app/src/main/java/com/scarriffle/calendarr/domain/model/OidcProvider.kt @@ -0,0 +1,13 @@ +package com.scarriffle.calendarr.domain.model + +/** + * One identity provider configured on the Calendarr server, as returned by + * `GET /api/auth/oidc/providers`. + */ +data class OidcProvider( + val key: String, + val name: String, + val issuer: String, + val scopes: String, + val mobileClientId: String?, +) diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt b/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt index fc2afa1..2436794 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/L10n.kt @@ -210,6 +210,21 @@ object L10n { "auth.password" to "Passwort", "auth.login" to "Anmelden", "auth.totp" to "2FA-Code", "auth.remember" to "Angemeldet bleiben", "auth.back" to "Zurück", + // Single sign-on (OpenID Connect) + "sso.or" to "oder", "sso.login_with" to "Mit %s anmelden", + "sso.err.generic" to "Anmeldung fehlgeschlagen", + "sso.err.oidc_failed" to "Anmeldung fehlgeschlagen", + "sso.err.oidc_provider_unreachable" to "Anmeldedienst nicht erreichbar", + "sso.err.oidc_no_browser" to "Kein Browser installiert — für die Anmeldung wird einer benötigt", + "sso.err.oidc_account_not_linked" to "Kein verknüpftes Calendarr-Konto. Bitte lokal anmelden und in den Einstellungen verknüpfen.", + "sso.err.oidc_signup_disabled" to "Automatische Kontoerstellung ist deaktiviert", + "sso.err.oidc_domain_not_allowed" to "Diese E-Mail-Domain ist nicht zugelassen", + "sso.err.oidc_email_conflict" to "Diese E-Mail-Adresse gehört bereits zu einem anderen Konto", + "sso.err.oidc_email_not_verified" to "E-Mail-Adresse beim Anbieter nicht bestätigt", + "sso.err.oidc_no_email" to "Der Anbieter hat keine E-Mail-Adresse übermittelt", + "sso.err.oidc_unknown_client" to "Diese App ist beim Anbieter nicht registriert", + "sso.err.oidc_invalid_token" to "Das Anmelde-Token ist ungültig", + "sso.err.oidc_no_id_token" to "Der Anbieter hat kein ID-Token geliefert", ), "en" to mapOf( "nav.today" to "Today", "nav.menu" to "Menu", "nav.done" to "Done", @@ -391,6 +406,21 @@ object L10n { "auth.password" to "Password", "auth.login" to "Sign in", "auth.totp" to "2FA code", "auth.remember" to "Stay signed in", "auth.back" to "Back", + // Single sign-on (OpenID Connect) + "sso.or" to "or", "sso.login_with" to "Sign in with %s", + "sso.err.generic" to "Sign-in failed", + "sso.err.oidc_failed" to "Sign-in failed", + "sso.err.oidc_provider_unreachable" to "Sign-in service unreachable", + "sso.err.oidc_no_browser" to "No browser installed — signing in requires one", + "sso.err.oidc_account_not_linked" to "No linked Calendarr account. Sign in locally and link it in Settings.", + "sso.err.oidc_signup_disabled" to "Automatic account creation is disabled", + "sso.err.oidc_domain_not_allowed" to "This email domain is not allowed", + "sso.err.oidc_email_conflict" to "That email address already belongs to another account", + "sso.err.oidc_email_not_verified" to "Email address not verified at the provider", + "sso.err.oidc_no_email" to "The provider did not supply an email address", + "sso.err.oidc_unknown_client" to "This app is not registered with the provider", + "sso.err.oidc_invalid_token" to "The sign-in token is invalid", + "sso.err.oidc_no_id_token" to "The provider did not return an ID token", ), ) } diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/auth/AuthViewModel.kt b/app/src/main/java/com/scarriffle/calendarr/ui/auth/AuthViewModel.kt index 034151f..1ce4d22 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/auth/AuthViewModel.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/auth/AuthViewModel.kt @@ -1,5 +1,7 @@ package com.scarriffle.calendarr.ui.auth +import android.content.ActivityNotFoundException +import android.content.Intent import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -7,16 +9,21 @@ 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.remote.ApiProvider +import com.scarriffle.calendarr.data.remote.OidcException import com.scarriffle.calendarr.data.remote.TwoFactorRequiredException +import com.scarriffle.calendarr.domain.model.OidcProvider import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch +import net.openid.appauth.AuthorizationException import javax.inject.Inject @HiltViewModel class AuthViewModel @Inject constructor( private val repository: CalendarRepository, private val credentialStore: CredentialStore, + private val oidc: OidcAuthenticator, ) : ViewModel() { // Server setup @@ -43,10 +50,26 @@ class AuthViewModel @Inject constructor( var loginError by mutableStateOf(null) private set + // Single sign-on + var ssoProviders by mutableStateOf>(emptyList()) + private set + /** Key of the provider whose flow is running, or null. Per-provider so a + * second button does not also show a spinner. */ + var ssoBusyKey by mutableStateOf(null) + private set + + /** Stable server error slug (e.g. `oidc_account_not_linked`). The screen + * turns it into a message; the ViewModel cannot call the translator. */ + var ssoErrorSlug by mutableStateOf(null) + private set + + private var loadingProviders = false + + fun onServerUrlChange(v: String) { serverUrl = v; setupError = null } - fun onUsernameChange(v: String) { username = v; loginError = null } - fun onPasswordChange(v: String) { password = v; loginError = null } - fun onTotpChange(v: String) { totpCode = v; loginError = null } + fun onUsernameChange(v: String) { username = v; loginError = null; ssoErrorSlug = null } + fun onPasswordChange(v: String) { password = v; loginError = null; ssoErrorSlug = null } + fun onTotpChange(v: String) { totpCode = v; loginError = null; ssoErrorSlug = null } fun onRememberChange(v: Boolean) { rememberMe = v } fun checkServer(onConfigured: () -> Unit) { @@ -65,6 +88,108 @@ class AuthViewModel @Inject constructor( } } + /** Load the configured providers. Silently yields an empty list when SSO + * is off or the server is older — the password form stays as it is. */ + fun loadSsoProviders() { + val base = credentialStore.serverUrl ?: return + if (ssoProviders.isNotEmpty() || loadingProviders) return + loadingProviders = true + viewModelScope.launch { + runCatching { repository.oidcProviders(base) } + .onSuccess { ssoProviders = it } + loadingProviders = false + } + } + + /** + * Start an SSO login: fetch the provider's configuration, build the PKCE + * request and open the Custom Tab. + * + * The provider key and client id are persisted before launching, because + * the browser runs in its own task and this process may well be killed + * while it is in the foreground. + */ + fun startSso(provider: OidcProvider, launch: (Intent) -> Unit) { + val clientId = provider.mobileClientId + if (clientId == null) { ssoErrorSlug = "oidc_unknown_client"; return } + viewModelScope.launch { + ssoBusyKey = provider.key + loginError = null + ssoErrorSlug = null + // `launch` has to stay inside runCatching: the result launcher is + // unregistered if the user leaves the screen while discovery is in + // flight, and the resulting IllegalStateException would otherwise + // escape viewModelScope and take the app down. + runCatching { + val config = oidc.discover(provider) + val request = oidc.buildRequest(config, provider) + credentialStore.oidcProvider = provider.key + credentialStore.oidcClientId = clientId + launch(oidc.authorizationIntent(request)) + }.onFailure { e -> + ssoBusyKey = null + ssoErrorSlug = when (e) { + // No browser at all needs a different remedy than "the + // login service is down". + is ActivityNotFoundException -> "oidc_no_browser" + else -> "oidc_provider_unreachable" + } + } + } + } + + /** Handle the browser's return: exchange the code, then trade the ID token. */ + fun finishSso(data: Intent?, onSuccess: () -> Unit) { + if (data == null) { ssoBusyKey = null; return } + + // AppAuth reports a user-cancelled flow as a normal result carrying + // USER_CANCELED_AUTH_FLOW — backing out of the browser is not an error. + val failure = AuthorizationException.fromIntent(data) + if (failure == AuthorizationException.GeneralErrors.USER_CANCELED_AUTH_FLOW) { + ssoBusyKey = null + return + } + + val providerKey = credentialStore.oidcProvider + val clientId = credentialStore.oidcClientId + if (providerKey == null || clientId == null) { + ssoBusyKey = null + ssoErrorSlug = "oidc_failed" + return + } + + viewModelScope.launch { + runCatching { + val (response, tokens) = oidc.exchangeCode(data) + val idToken = tokens.idToken ?: throw OidcException("oidc_no_id_token") + val result = repository.exchangeOidc( + baseUrl = credentialStore.serverUrl ?: error("no server"), + provider = providerKey, + clientId = clientId, + idToken = idToken, + accessToken = tokens.accessToken, + // AppAuth round-trips the original request through the + // redirect, so the nonce survives process death. + nonce = response.request.nonce, + ) + // Keep the provider's refresh token so the session can be + // renewed later without another trip through the browser. + credentialStore.saveOidcSession( + state = oidc.stateFor(response, tokens), + provider = providerKey, + clientId = clientId, + ) + result + }.onSuccess { + ssoBusyKey = null + onSuccess() + }.onFailure { e -> + ssoBusyKey = null + ssoErrorSlug = if (e is OidcException) e.slug else "oidc_failed" + } + } + } + fun login(onSuccess: () -> Unit) { val base = credentialStore.serverUrl ?: return if (username.isBlank() || password.isBlank()) { diff --git a/app/src/main/java/com/scarriffle/calendarr/ui/auth/LoginScreen.kt b/app/src/main/java/com/scarriffle/calendarr/ui/auth/LoginScreen.kt index 0fd6a11..afa5d9a 100644 --- a/app/src/main/java/com/scarriffle/calendarr/ui/auth/LoginScreen.kt +++ b/app/src/main/java/com/scarriffle/calendarr/ui/auth/LoginScreen.kt @@ -21,8 +21,13 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Switch import androidx.compose.material3.Text +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.material3.Divider +import androidx.compose.material3.OutlinedButton import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.ImeAction @@ -40,6 +45,14 @@ fun LoginScreen( onBack: () -> Unit, vm: AuthViewModel = hiltViewModel(), ) { + // Browser round-trip for SSO. AppAuth returns the authorization response + // as an Activity result, so nothing has to be wired into MainActivity. + val ssoLauncher = rememberLauncherForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> vm.finishSso(result.data, onLoggedIn) } + + LaunchedEffect(Unit) { vm.loadSsoProviders() } + Column(modifier = Modifier.fillMaxSize().padding(horizontal = 28.dp)) { Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.padding(top = 12.dp)) { IconButton(onClick = onBack) { @@ -96,6 +109,14 @@ fun LoginScreen( Spacer(Modifier.height(8.dp)) Text(it, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) } + vm.ssoErrorSlug?.let { slug -> + // Unknown slugs fall through to the generic message rather than + // showing the raw key. + val key = "sso.err.$slug" + val msg = tr(key).takeIf { it != key } ?: tr("sso.err.generic") + Spacer(Modifier.height(8.dp)) + Text(msg, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + } Spacer(Modifier.height(20.dp)) Button( onClick = { vm.login(onLoggedIn) }, @@ -108,6 +129,33 @@ fun LoginScreen( Text(tr("auth.login")) } } + if (vm.ssoProviders.isNotEmpty()) { + Spacer(Modifier.height(20.dp)) + Row(verticalAlignment = Alignment.CenterVertically) { + Divider(modifier = Modifier.weight(1f)) + Text( + tr("sso.or"), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 12.dp), + ) + Divider(modifier = Modifier.weight(1f)) + } + Spacer(Modifier.height(12.dp)) + vm.ssoProviders.forEach { provider -> + OutlinedButton( + onClick = { vm.startSso(provider) { intent -> ssoLauncher.launch(intent) } }, + enabled = vm.ssoBusyKey == null && !vm.loggingIn, + modifier = Modifier.fillMaxWidth().padding(bottom = 8.dp), + ) { + if (vm.ssoBusyKey == provider.key) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + } else { + Text(tr("sso.login_with", provider.name)) + } + } + } + } TextButton(onClick = onBack) { Text(tr("server.switch")) } } }